Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c19ff041 | |||
| 80c9857475 | |||
| c147a6c010 | |||
| 88a720543e | |||
| e780e3731d | |||
| 853cda603e | |||
| 5f8287f389 | |||
| 8fa7cbc060 | |||
| fe1b0fcac3 | |||
| 6f9a1a907d | |||
| 82bb72fc82 | |||
| 6812ff84b9 | |||
| 3096985466 | |||
| ce9e6d4379 | |||
| 1f376d4b5d | |||
| cfb830a694 | |||
| c072b4a932 | |||
| 9fe67eaeb2 | |||
| 389501239c | |||
| 9512813b58 | |||
| bebf636b4e | |||
| 1db1acf44d | |||
| be58a1c04e | |||
| fbdc98f8bd | |||
| ba79cf292f | |||
| 93b870a139 | |||
| cfa50739d8 | |||
| 86eedf1985 | |||
| 1bc7a1c143 | |||
| d6241d31f8 | |||
| a6465b0b0b | |||
| d1b28b669f | |||
| 68452974f1 | |||
| 09810d0b6c | |||
| 7358c4e3fa | |||
| 7380f4a15e | |||
| dbb11957ae | |||
| 151b72baff | |||
| d716e261b4 | |||
| 7ba21d058c | |||
| bbc64f685e | |||
| 2c28fda5e3 | |||
| 5a9ae02f61 | |||
| da6d971de4 | |||
| b4e7dfd099 | |||
| 39577b31bd | |||
| 6b039c1cb0 | |||
| 2cbff15837 | |||
| 921a36c94d | |||
| 14412871bf | |||
| afed43571d | |||
| 773dc11888 | |||
| e0f6dd4097 | |||
| 711ad05983 | |||
| 2d0a9f334b | |||
| 97996048ae | |||
| 66bd7f758e | |||
| c51158bad1 | |||
| ccf881c054 | |||
| 1f780a0ae8 | |||
| 17ac08af8f | |||
| 214488cf80 | |||
| c6c842adc9 | |||
| 4cb23dc2b1 | |||
| 7c9285061a | |||
| 0edfa973e4 | |||
| 0be20d514e | |||
| e414ae40d4 | |||
| b54b2a2dc2 | |||
| f545c0d7ad | |||
| 18142201bd | |||
| 50320ba868 | |||
| 12b05a7d0a | |||
| 7e19a5a1ff | |||
| 096e3a1629 | |||
| 51a5c4a65e | |||
| e5472b4c67 | |||
| ebf4c07142 | |||
| 4ab8aac38f | |||
| 8d32b1463a | |||
| ec09142445 | |||
| 1e3c713c95 | |||
| 1747170ec3 | |||
| 1e436189b7 | |||
| a247485a38 | |||
| b352cb2701 | |||
| b0b2c3f8d8 | |||
| aba98031f7 | |||
| 66ec241c48 | |||
| 2ee80b41e0 | |||
| c1aa08b266 | |||
| 7b7bdc3d88 | |||
| 71588fda63 | |||
| d4b1b20016 | |||
| 952face2ae | |||
| fa8c7bd460 | |||
| de8c506618 | |||
| 18a7d179f8 | |||
| 56c6e97874 | |||
| b574c01264 | |||
| 6d64ba8ffd | |||
| 6d0a85b7be | |||
| 33d5da9530 | |||
| 2a92004fbf | |||
| 97ec8594d9 | |||
| 713a2cd924 | |||
| 0d4fd3d6e9 | |||
| 7cc9d8dc18 | |||
| f896d59609 | |||
| 707858900a | |||
| de0b361062 | |||
| 111b038fec | |||
| a2f90d5de0 | |||
| 084363b3d6 | |||
| 9c0dfa7aa3 | |||
| c4bf18cf0f | |||
| 9663eb4931 | |||
| da846df924 | |||
| a5bb663f9e | |||
| b234905860 | |||
| f658cfcd03 | |||
| 56297cde91 | |||
| f51c74d09e | |||
| b06da932cd | |||
| f869b73bf5 | |||
| 4f40cb8862 | |||
| 161e9b575c | |||
| 42ccd3f3c1 | |||
| 5dcb37032e | |||
| e42bf2de73 | |||
| 4225404673 | |||
| cbfa388568 | |||
| d471a84125 | |||
| 2cc18422fd | |||
| 4ba60dab16 | |||
| 588e4acbd3 | |||
| 3a67bbe298 | |||
| eb730ecd6e | |||
| 112da2c873 | |||
| 37ff3c0d17 | |||
| 1451bfac12 | |||
| c445e24ff6 | |||
| bb5af66656 | |||
| 85e8b7fc24 | |||
| c4802fe442 | |||
| 91ee1d96b3 | |||
| 19fe40b172 | |||
| 355ac7a0fa | |||
| 361a8a6297 | |||
| 0b68aa85c7 | |||
| 3105b811d4 | |||
| b438b6df61 | |||
| 15bda0560a | |||
| 3433ea6aef | |||
| 745ebf64ef | |||
| 80ff06ef24 | |||
| f270ac9f9d | |||
| 7d5d89302b | |||
| 93668da99f | |||
| 19be4348aa |
+38
-4
@@ -14,6 +14,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -221,7 +226,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
@@ -232,6 +237,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -268,9 +302,9 @@ MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, xray
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режима external_link)
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
@@ -330,4 +364,4 @@ LOG_FILE=logs/bot.log
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.8-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.8-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.8"
|
||||
ARG VERSION="v2.3.9"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
@@ -112,8 +112,8 @@ docker compose logs
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|--------|
|
||||
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (просто ссылка) или `xray` (интеграция с XrayChecker). | `xray` |
|
||||
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режиме `external_link`). | `https://status.example.com` |
|
||||
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (открывает ссылку в браузере), `external_link_miniapp` (открывает ссылку во встроенном мини-приложении Telegram) или `xray` (интеграция с XrayChecker). | `xray` |
|
||||
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режимах `external_link` и `external_link_miniapp`). | `https://status.example.com` |
|
||||
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` |
|
||||
| `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` |
|
||||
| `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` |
|
||||
@@ -248,11 +248,12 @@ ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -274,6 +275,7 @@ POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
@@ -300,6 +302,11 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
@@ -437,6 +444,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
@@ -447,7 +461,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
@@ -458,6 +472,41 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# Настройки PayPalych
|
||||
1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений.
|
||||
2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`.
|
||||
3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол.
|
||||
4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»).
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -493,6 +542,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
@@ -540,8 +606,6 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -573,6 +637,8 @@ WEBHOOK_PATH=/webhook
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💳 MulenPay
|
||||
- 💳 PayPalych (Pal24)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
@@ -598,7 +664,7 @@ WEBHOOK_PATH=/webhook
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
|
||||
@@ -891,6 +957,7 @@ docker compose down -v --remove-orphans
|
||||
- **Telegram Stars**: Работает автоматически
|
||||
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
|
||||
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
|
||||
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
|
||||
|
||||
|
||||
### 🛠️ Настройка Уведомлений в топик группы
|
||||
@@ -1103,7 +1170,7 @@ docker system prune
|
||||
|----------|-------------|---------|
|
||||
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
|
||||
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
|
||||
| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно |
|
||||
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно |
|
||||
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ |
|
||||
| **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` |
|
||||
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
|
||||
@@ -1124,20 +1191,41 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
|
||||
# CryptoBot webhook endpoint
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
location /cryptobot-webhook {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# MulenPay webhook endpoint
|
||||
location /mulenpay-webhook {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# PayPalych webhook endpoint
|
||||
location /pal24-webhook {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Для YooKassa
|
||||
location /yookassa-webhook {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check
|
||||
@@ -1153,21 +1241,48 @@ your-domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /mulenpay-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /pal24-webhook* {
|
||||
reverse_proxy localhost:8084
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
}
|
||||
|
||||
|
||||
handle /health {
|
||||
reverse_proxy localhost:8081/health
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 🧪 Проверка PayPalych postback
|
||||
|
||||
```bash
|
||||
# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}")
|
||||
SIGNATURE=$(python - <<'PY'
|
||||
import hashlib, os
|
||||
token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token')
|
||||
payload = f"100.00:test-order-1:{token}".encode()
|
||||
print(hashlib.md5(payload).hexdigest().upper())
|
||||
PY
|
||||
)
|
||||
|
||||
curl -X POST https://your-domain.com/pal24-webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}'
|
||||
```
|
||||
|
||||
Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Использование
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.handlers.admin import (
|
||||
backup as admin_backup,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -139,6 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+92
-30
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -16,12 +18,22 @@ class Settings(BaseSettings):
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
# SLA for support tickets
|
||||
SUPPORT_TICKET_SLA_ENABLED: bool = True
|
||||
SUPPORT_TICKET_SLA_MINUTES: int = 5
|
||||
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
|
||||
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_REPORTS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_REPORTS_SEND_TIME: Optional[str] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
@@ -169,6 +181,32 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
PAL24_SHOP_ID: Optional[str] = None
|
||||
PAL24_SIGNATURE_TOKEN: Optional[str] = None
|
||||
PAL24_BASE_URL: str = "https://pal24.pro/api/v1/"
|
||||
PAL24_WEBHOOK_PATH: str = "/pal24-webhook"
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PAL24_REQUEST_TIMEOUT: int = 30
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
@@ -221,6 +259,13 @@ class Settings(BaseSettings):
|
||||
"link": "external_link",
|
||||
"url": "external_link",
|
||||
"external_link": "external_link",
|
||||
"miniapp": "external_link_miniapp",
|
||||
"mini_app": "external_link_miniapp",
|
||||
"mini-app": "external_link_miniapp",
|
||||
"webapp": "external_link_miniapp",
|
||||
"web_app": "external_link_miniapp",
|
||||
"web-app": "external_link_miniapp",
|
||||
"external_link_miniapp": "external_link_miniapp",
|
||||
"xray": "xray",
|
||||
"xraychecker": "xray",
|
||||
"xray_metrics": "xray",
|
||||
@@ -228,8 +273,10 @@ class Settings(BaseSettings):
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {"disabled", "external_link", "xray"}:
|
||||
raise ValueError("SERVER_STATUS_MODE must be one of: disabled, external_link, xray")
|
||||
if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}:
|
||||
raise ValueError(
|
||||
"SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray"
|
||||
)
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before')
|
||||
@@ -387,6 +434,32 @@ class Settings(BaseSettings):
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
|
||||
def get_reports_chat_id(self) -> Optional[str]:
|
||||
if self.ADMIN_REPORTS_CHAT_ID:
|
||||
return self.ADMIN_REPORTS_CHAT_ID
|
||||
return self.ADMIN_NOTIFICATIONS_CHAT_ID
|
||||
|
||||
def get_reports_topic_id(self) -> Optional[int]:
|
||||
return self.ADMIN_REPORTS_TOPIC_ID or None
|
||||
|
||||
def get_reports_send_time(self) -> Optional[time]:
|
||||
value = self.ADMIN_REPORTS_SEND_TIME
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours_str, minutes_str = value.strip().split(":", 1)
|
||||
hours = int(hours_str)
|
||||
minutes = int(minutes_str)
|
||||
if not (0 <= hours <= 23 and 0 <= minutes <= 59):
|
||||
raise ValueError
|
||||
return time(hour=hours, minute=minutes)
|
||||
except (ValueError, AttributeError):
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value
|
||||
)
|
||||
return None
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
@@ -435,9 +508,24 @@ class Settings(BaseSettings):
|
||||
return "https://t.me/"
|
||||
|
||||
def is_cryptobot_enabled(self) -> bool:
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
and self.MULENPAY_API_KEY is not None
|
||||
and self.MULENPAY_SECRET_KEY is not None
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
and self.PAL24_API_TOKEN is not None
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -841,32 +929,6 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subscription_id: Optional[int],
|
||||
notification_type: str,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.notification_type == notification_type,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offer = result.scalars().first()
|
||||
|
||||
if offer and offer.claimed_at is None:
|
||||
offer.discount_percent = discount_percent
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
if not offers:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for offer in offers:
|
||||
offer.is_active = False
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
return count
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
payment_url: Optional[str],
|
||||
mulen_payment_id: Optional[int],
|
||||
currency: str,
|
||||
status: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment = MulenPayPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency=currency,
|
||||
status=status,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_local_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_uuid(
|
||||
db: AsyncSession, uuid: str
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_mulen_id(
|
||||
db: AsyncSession, mulen_payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(
|
||||
MulenPayPayment.mulen_payment_id == mulen_payment_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_mulenpay_payment_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
status: str,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at:
|
||||
payment.paid_at = paid_at
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,160 @@
|
||||
"""CRUD helpers for PayPalych (Pal24) payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: str,
|
||||
currency: str,
|
||||
link_url: Optional[str],
|
||||
link_page_url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
payment = Pal24Payment(
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
metadata_json=metadata or {},
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_pal24_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
*,
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
balance_amount: Optional[str] = None,
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if is_active is not None:
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
update_values["payment_status"] = payment_status
|
||||
if payment_method is not None:
|
||||
update_values["payment_method"] = payment_method
|
||||
if balance_amount is not None:
|
||||
update_values["balance_amount"] = balance_amount
|
||||
if balance_currency is not None:
|
||||
update_values["balance_currency"] = balance_currency
|
||||
if payer_account is not None:
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_pal24_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
"Pal24 платеж %s привязан к транзакции %s",
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,6 +7,24 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
if not period_discounts:
|
||||
return {}
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in period_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,12 +58,24 @@ async def create_promo_group(
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks)
|
||||
if auto_assign_total_spent_kopeks is not None
|
||||
else None
|
||||
)
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
@@ -54,11 +84,13 @@ async def create_promo_group(
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽",
|
||||
promo_group.name,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
normalized_period_discounts,
|
||||
(auto_assign_total_spent_kopeks or 0) / 100,
|
||||
)
|
||||
|
||||
return promo_group
|
||||
@@ -72,6 +104,8 @@ async def update_promo_group(
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -81,6 +115,11 @@ async def update_promo_group(
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer, Subscription
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
@@ -18,9 +26,30 @@ async def create_server_squad(
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
is_available: bool = True,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
|
||||
normalized_group_ids: Sequence[int]
|
||||
if promo_group_ids is None:
|
||||
default_id = await _get_default_promo_group_id(db)
|
||||
normalized_group_ids = [default_id] if default_id is not None else []
|
||||
else:
|
||||
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not normalized_group_ids:
|
||||
raise ValueError("Server squad must be linked to at least one promo group")
|
||||
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
|
||||
if len(promo_groups) != len(normalized_group_ids):
|
||||
logger.warning(
|
||||
"Не все промогруппы найдены при создании сервера %s", display_name
|
||||
)
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
@@ -29,9 +58,10 @@ async def create_server_squad(
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
is_available=is_available,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
@@ -46,9 +76,11 @@ async def get_server_squad_by_uuid(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
@@ -57,9 +89,11 @@ async def get_server_squad_by_id(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
@@ -91,14 +125,59 @@ async def get_all_server_squads(
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
async def get_available_server_squads(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = (
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.is_available.is_(True))
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(
|
||||
PromoGroup.id == promo_group_id
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not unique_ids:
|
||||
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
|
||||
)
|
||||
promo_groups = result.scalars().all()
|
||||
|
||||
if not promo_groups:
|
||||
raise ValueError("Не найдены промогруппы для обновления сервера")
|
||||
|
||||
server.allowed_promo_groups = promo_groups
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
logger.info(
|
||||
"Обновлены промогруппы сервера %s (ID: %s): %s",
|
||||
server.display_name,
|
||||
server.id,
|
||||
", ".join(pg.name for pg in promo_groups),
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
|
||||
@@ -531,7 +531,15 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -577,6 +585,9 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
@@ -87,6 +87,40 @@ class TicketCRUD:
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
"""Подсчитать количество тикетов пользователя по списку статусов"""
|
||||
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str],
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя по списку статусов с пагинацией"""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user_id)
|
||||
.order_by(desc(Ticket.updated_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
@@ -239,6 +273,54 @@ class TicketCRUD:
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_user_id: Optional[int],
|
||||
actor_telegram_id: int,
|
||||
is_moderator: bool,
|
||||
action: str,
|
||||
ticket_id: Optional[int] = None,
|
||||
target_user_id: Optional[int] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
try:
|
||||
log = SupportAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
actor_telegram_id=actor_telegram_id,
|
||||
is_moderator=bool(is_moderator),
|
||||
action=action,
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=target_user_id,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
# не мешаем основной логике
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
@@ -291,6 +373,14 @@ class TicketMessageCRUD:
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
|
||||
try:
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
# если колонка существует в модели
|
||||
if hasattr(ticket, 'last_sla_reminder_at'):
|
||||
ticket.last_sla_reminder_at = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
@@ -37,6 +37,20 @@ async def create_transaction(
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -98,15 +112,47 @@ async def get_user_transactions_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
transaction.user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ async def create_user(
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
|
||||
+241
-22
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -14,6 +14,8 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -23,6 +25,24 @@ from sqlalchemy.sql import func
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
server_squad_promo_groups = Table(
|
||||
"server_squad_promo_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"server_squad_id",
|
||||
Integer,
|
||||
ForeignKey("server_squads.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"promo_group_id",
|
||||
Integer,
|
||||
ForeignKey("promo_groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
@@ -53,9 +73,11 @@ class PromoCodeType(Enum):
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
MANUAL = "manual"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
MANUAL = "manual"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
@@ -107,7 +129,7 @@ class YooKassaPayment(Base):
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = "cryptobot_payments"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
@@ -155,6 +177,111 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
mulen_payment_id = Column(Integer, nullable=True, index=True)
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False, default="created")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="mulenpay_payments")
|
||||
transaction = relationship("Transaction", backref="mulenpay_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<MulenPayPayment(id={0}, mulen_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.mulen_payment_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Pal24Payment(Base):
|
||||
__tablename__ = "pal24_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
bill_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(20), nullable=False, default="normal")
|
||||
|
||||
status = Column(String(50), nullable=False, default="NEW")
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
last_status = Column(String(50), nullable=True)
|
||||
last_status_checked_at = Column(DateTime, nullable=True)
|
||||
|
||||
link_url = Column(Text, nullable=True)
|
||||
link_page_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
payment_id = Column(String(255), nullable=True, index=True)
|
||||
payment_status = Column(String(50), nullable=True)
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
balance_amount = Column(String(50), nullable=True)
|
||||
balance_currency = Column(String(10), nullable=True)
|
||||
payer_account = Column(String(255), nullable=True)
|
||||
|
||||
ttl = Column(Integer, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="pal24_payments")
|
||||
transaction = relationship("Transaction", backref="pal24_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in {"NEW", "PROCESS"}
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.bill_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
@@ -163,13 +290,66 @@ class PromoGroup(Base):
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
period_discounts = Column(JSON, nullable=True, default=dict)
|
||||
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="allowed_promo_groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def _get_period_discounts_map(self) -> Dict[int, int]:
|
||||
raw_discounts = self.period_discounts or {}
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
items = raw_discounts.items()
|
||||
else:
|
||||
items = []
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in items:
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
def _get_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days:
|
||||
return 0
|
||||
|
||||
discounts = self._get_period_discounts_map()
|
||||
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_base_promo_group_period_discount_enabled():
|
||||
config_discounts = settings.get_base_promo_group_period_discounts()
|
||||
return config_discounts.get(period_days, 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if category == "period":
|
||||
return max(0, min(100, self._get_period_discount(period_days)))
|
||||
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
@@ -177,24 +357,12 @@ class PromoGroup(Base):
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
|
||||
if self.is_default and period_days is not None:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_base_promo_group_period_discount_enabled():
|
||||
discounts = settings.get_base_promo_group_period_discounts()
|
||||
if period_days in discounts:
|
||||
period_discount = discounts[period_days]
|
||||
percent = period_discount
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
@@ -216,7 +384,9 @@ class User(Base):
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
@@ -277,8 +447,9 @@ class Subscription(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -622,6 +793,28 @@ class SentNotification(Base):
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
|
||||
class DiscountOffer(Base):
|
||||
__tablename__ = "discount_offers"
|
||||
__table_args__ = (
|
||||
Index("ix_discount_offers_user_type", "user_id", "notification_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="discount_offers")
|
||||
subscription = relationship("Subscription", back_populates="discount_offers")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
@@ -644,9 +837,9 @@ class BroadcastHistory(Base):
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
@@ -664,10 +857,17 @@ class ServerSquad(Base):
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
allowed_promo_groups = relationship(
|
||||
"PromoGroup",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="server_squads",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
@@ -703,6 +903,23 @@ class SubscriptionServer(Base):
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
|
||||
|
||||
class SupportAuditLog(Base):
|
||||
__tablename__ = "support_audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
actor_telegram_id = Column(BigInteger, nullable=False)
|
||||
is_moderator = Column(Boolean, default=False)
|
||||
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
actor = relationship("User", foreign_keys=[actor_user_id])
|
||||
ticket = relationship("Ticket", foreign_keys=[ticket_id])
|
||||
|
||||
class UserMessage(Base):
|
||||
__tablename__ = "user_messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -810,6 +1027,8 @@ class Ticket(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
# SLA reminders
|
||||
last_sla_reminder_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", backref="tickets")
|
||||
|
||||
@@ -274,6 +274,429 @@ async def create_cryptobot_payments_table():
|
||||
logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_mulenpay_payments_table():
|
||||
table_exists = await check_table_exists('mulenpay_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица mulenpay_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE mulenpay_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
mulen_payment_id INTEGER NULL,
|
||||
uuid VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'created',
|
||||
is_paid BOOLEAN DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid);
|
||||
CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE mulenpay_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
mulen_payment_id INTEGER NULL,
|
||||
uuid VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'created',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
paid_at TIMESTAMP NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid);
|
||||
CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE mulenpay_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
mulen_payment_id INT NULL,
|
||||
uuid VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'created',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
transaction_id INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid);
|
||||
CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица mulenpay_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_mulenpay_payment_schema() -> bool:
|
||||
logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ MULEN PAY ===")
|
||||
|
||||
table_exists = await check_table_exists("mulenpay_payments")
|
||||
if not table_exists:
|
||||
logger.warning("⚠️ Таблица mulenpay_payments отсутствует — создаём заново")
|
||||
return await create_mulenpay_payments_table()
|
||||
|
||||
try:
|
||||
column_exists = await check_column_exists("mulenpay_payments", "mulen_payment_id")
|
||||
paid_at_column_exists = await check_column_exists("mulenpay_payments", "paid_at")
|
||||
index_exists = await check_index_exists("mulenpay_payments", "idx_mulenpay_payment_id")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not column_exists:
|
||||
if db_type == "sqlite":
|
||||
alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL"
|
||||
elif db_type == "postgresql":
|
||||
alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL"
|
||||
elif db_type == "mysql":
|
||||
alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL"
|
||||
else:
|
||||
logger.error(
|
||||
"Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s",
|
||||
db_type,
|
||||
)
|
||||
return False
|
||||
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка mulenpay_payments.mulen_payment_id")
|
||||
else:
|
||||
logger.info("ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует")
|
||||
|
||||
if not paid_at_column_exists:
|
||||
if db_type == "sqlite":
|
||||
alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL"
|
||||
elif db_type == "postgresql":
|
||||
alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at TIMESTAMP NULL"
|
||||
elif db_type == "mysql":
|
||||
alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL"
|
||||
else:
|
||||
logger.error(
|
||||
"Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s",
|
||||
db_type,
|
||||
)
|
||||
return False
|
||||
|
||||
await conn.execute(text(alter_paid_at_sql))
|
||||
logger.info("✅ Добавлена колонка mulenpay_payments.paid_at")
|
||||
else:
|
||||
logger.info("ℹ️ Колонка mulenpay_payments.paid_at уже существует")
|
||||
|
||||
if not index_exists:
|
||||
if db_type == "sqlite":
|
||||
create_index_sql = (
|
||||
"CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id "
|
||||
"ON mulenpay_payments(mulen_payment_id)"
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
create_index_sql = (
|
||||
"CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id "
|
||||
"ON mulenpay_payments(mulen_payment_id)"
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
create_index_sql = (
|
||||
"CREATE INDEX idx_mulenpay_payment_id "
|
||||
"ON mulenpay_payments(mulen_payment_id)"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s",
|
||||
db_type,
|
||||
)
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_index_sql))
|
||||
logger.info("✅ Создан индекс idx_mulenpay_payment_id")
|
||||
else:
|
||||
logger.info("ℹ️ Индекс idx_mulenpay_payment_id уже существует")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления схемы mulenpay_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_pal24_payments_table():
|
||||
table_exists = await check_table_exists('pal24_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица pal24_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE pal24_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
bill_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
last_status_checked_at DATETIME NULL,
|
||||
link_url TEXT NULL,
|
||||
link_page_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
payment_id VARCHAR(255) NULL,
|
||||
payment_status VARCHAR(50) NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
balance_amount VARCHAR(50) NULL,
|
||||
balance_currency VARCHAR(10) NULL,
|
||||
payer_account VARCHAR(255) NULL,
|
||||
ttl INTEGER NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
|
||||
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
|
||||
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE pal24_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
bill_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
paid_at TIMESTAMP NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
last_status_checked_at TIMESTAMP NULL,
|
||||
link_url TEXT NULL,
|
||||
link_page_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
payment_id VARCHAR(255) NULL,
|
||||
payment_status VARCHAR(50) NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
balance_amount VARCHAR(50) NULL,
|
||||
balance_currency VARCHAR(10) NULL,
|
||||
payer_account VARCHAR(255) NULL,
|
||||
ttl INTEGER NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
|
||||
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
|
||||
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE pal24_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
bill_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
last_status_checked_at DATETIME NULL,
|
||||
link_url TEXT NULL,
|
||||
link_page_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
payment_id VARCHAR(255) NULL,
|
||||
payment_status VARCHAR(50) NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
balance_amount VARCHAR(50) NULL,
|
||||
balance_currency VARCHAR(10) NULL,
|
||||
payer_account VARCHAR(255) NULL,
|
||||
ttl INT NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
|
||||
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
|
||||
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица pal24_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы pal24_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_discount_offers_table():
|
||||
table_exists = await check_table_exists('discount_offers')
|
||||
if table_exists:
|
||||
logger.info("Таблица discount_offers уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE discount_offers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subscription_id INTEGER NULL,
|
||||
notification_type VARCHAR(50) NOT NULL,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type
|
||||
ON discount_offers (user_id, notification_type)
|
||||
"""))
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS discount_offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||
notification_type VARCHAR(50) NOT NULL,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
claimed_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type
|
||||
ON discount_offers (user_id, notification_type)
|
||||
"""))
|
||||
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS discount_offers (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subscription_id INTEGER NULL,
|
||||
notification_type VARCHAR(50) NOT NULL,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX ix_discount_offers_user_type
|
||||
ON discount_offers (user_id, notification_type)
|
||||
"""))
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица discount_offers успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы discount_offers: {e}")
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
@@ -435,6 +858,79 @@ async def ensure_promo_groups_setup():
|
||||
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
|
||||
)
|
||||
|
||||
period_discounts_column_exists = await check_column_exists(
|
||||
"promo_groups", "period_discounts"
|
||||
)
|
||||
|
||||
if not period_discounts_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
|
||||
)
|
||||
await conn.execute(
|
||||
text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL"
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Добавлена колонка promo_groups.period_discounts")
|
||||
|
||||
auto_assign_column_exists = await check_column_exists(
|
||||
"promo_groups", "auto_assign_total_spent_kopeks"
|
||||
)
|
||||
|
||||
if not auto_assign_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0"
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Добавлена колонка promo_groups.auto_assign_total_spent_kopeks"
|
||||
)
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
if not column_exists:
|
||||
@@ -450,6 +946,37 @@ async def ensure_promo_groups_setup():
|
||||
|
||||
logger.info("Добавлена колонка users.promo_group_id")
|
||||
|
||||
auto_promo_flag_exists = await check_column_exists(
|
||||
"users", "auto_promo_group_assigned"
|
||||
)
|
||||
|
||||
if not auto_promo_flag_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0"
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Добавлена колонка users.auto_promo_group_assigned")
|
||||
|
||||
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
|
||||
|
||||
if not index_exists:
|
||||
@@ -815,6 +1342,30 @@ async def add_ticket_reply_block_columns():
|
||||
logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_ticket_sla_columns():
|
||||
try:
|
||||
col_exists = await check_column_exists('tickets', 'last_sla_reminder_at')
|
||||
if col_exists:
|
||||
return True
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}")
|
||||
return False
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления SLA колонки в tickets: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -1053,14 +1604,121 @@ async def fix_subscription_duplicates_universal():
|
||||
deleted_count = delete_result.rowcount
|
||||
total_deleted += deleted_count
|
||||
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
|
||||
|
||||
|
||||
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_server_promo_groups_setup() -> bool:
|
||||
logger.info("=== НАСТРОЙКА ДОСТУПА СЕРВЕРОВ К ПРОМОГРУППАМ ===")
|
||||
|
||||
try:
|
||||
table_exists = await check_table_exists("server_squad_promo_groups")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not table_exists:
|
||||
if db_type == "sqlite":
|
||||
create_table_sql = """
|
||||
CREATE TABLE server_squad_promo_groups (
|
||||
server_squad_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_squad_id, promo_group_id),
|
||||
FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
create_index_sql = """
|
||||
CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_table_sql = """
|
||||
CREATE TABLE server_squad_promo_groups (
|
||||
server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE,
|
||||
promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (server_squad_id, promo_group_id)
|
||||
);
|
||||
"""
|
||||
create_index_sql = """
|
||||
CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id);
|
||||
"""
|
||||
else:
|
||||
create_table_sql = """
|
||||
CREATE TABLE server_squad_promo_groups (
|
||||
server_squad_id INT NOT NULL,
|
||||
promo_group_id INT NOT NULL,
|
||||
PRIMARY KEY (server_squad_id, promo_group_id),
|
||||
FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
create_index_sql = """
|
||||
CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_table_sql))
|
||||
await conn.execute(text(create_index_sql))
|
||||
logger.info("✅ Таблица server_squad_promo_groups создана")
|
||||
else:
|
||||
logger.info("ℹ️ Таблица server_squad_promo_groups уже существует")
|
||||
|
||||
default_query = (
|
||||
"SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1"
|
||||
if db_type == "postgresql"
|
||||
else "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1"
|
||||
)
|
||||
default_result = await conn.execute(text(default_query))
|
||||
default_row = default_result.fetchone()
|
||||
|
||||
if not default_row:
|
||||
logger.warning("⚠️ Не найдена базовая промогруппа для назначения серверам")
|
||||
return True
|
||||
|
||||
default_group_id = default_row[0]
|
||||
|
||||
servers_result = await conn.execute(text("SELECT id FROM server_squads"))
|
||||
server_ids = [row[0] for row in servers_result.fetchall()]
|
||||
|
||||
assigned_count = 0
|
||||
for server_id in server_ids:
|
||||
existing = await conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM server_squad_promo_groups WHERE server_squad_id = :sid LIMIT 1"
|
||||
),
|
||||
{"sid": server_id},
|
||||
)
|
||||
if existing.fetchone():
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) "
|
||||
"VALUES (:sid, :gid)"
|
||||
),
|
||||
{"sid": server_id, "gid": default_group_id},
|
||||
)
|
||||
assigned_count += 1
|
||||
|
||||
if assigned_count:
|
||||
logger.info(
|
||||
f"✅ Базовая промогруппа назначена {assigned_count} серверам"
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ Все серверы уже имеют назначенные промогруппы")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка настройки таблицы server_squad_promo_groups: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -1079,6 +1737,33 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
|
||||
mulenpay_created = await create_mulenpay_payments_table()
|
||||
if mulenpay_created:
|
||||
logger.info("✅ Таблица Mulen Pay payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
|
||||
|
||||
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
|
||||
if mulenpay_schema_ok:
|
||||
logger.info("✅ Схема Mulen Pay payments актуальна")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
|
||||
pal24_created = await create_pal24_payments_table()
|
||||
if pal24_created:
|
||||
logger.info("✅ Таблица Pal24 payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
|
||||
discount_created = await create_discount_offers_table()
|
||||
if discount_created:
|
||||
logger.info("✅ Таблица discount_offers готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей discount_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
@@ -1107,6 +1792,79 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===")
|
||||
sla_cols_added = await add_ticket_sla_columns()
|
||||
if sla_cols_added:
|
||||
logger.info("✅ Поля SLA в tickets готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
if not await check_table_exists('support_audit_logs'):
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE support_audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_user_id INTEGER NULL,
|
||||
actor_telegram_id BIGINT NOT NULL,
|
||||
is_moderator BOOLEAN NOT NULL DEFAULT 0,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
ticket_id INTEGER NULL,
|
||||
target_user_id INTEGER NULL,
|
||||
details JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id),
|
||||
FOREIGN KEY (target_user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
|
||||
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
|
||||
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE support_audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
actor_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_telegram_id BIGINT NOT NULL,
|
||||
is_moderator BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
ticket_id INTEGER NULL REFERENCES tickets(id) ON DELETE SET NULL,
|
||||
target_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
details JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
|
||||
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
|
||||
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE support_audit_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
actor_user_id INT NULL,
|
||||
actor_telegram_id BIGINT NOT NULL,
|
||||
is_moderator BOOLEAN NOT NULL DEFAULT 0,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
ticket_id INT NULL,
|
||||
target_user_id INT NULL,
|
||||
details JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
|
||||
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
|
||||
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
|
||||
"""
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица support_audit_logs создана")
|
||||
else:
|
||||
logger.info("ℹ️ Таблица support_audit_logs уже существует")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}")
|
||||
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
promo_groups_ready = await ensure_promo_groups_setup()
|
||||
if promo_groups_ready:
|
||||
@@ -1114,6 +1872,12 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой промо групп")
|
||||
|
||||
server_promo_groups_ready = await ensure_server_promo_groups_setup()
|
||||
if server_promo_groups_ready:
|
||||
logger.info("✅ Доступ серверов по промогруппам настроен")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -1186,7 +1950,11 @@ async def check_migration_status():
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"users_promo_group_column": False
|
||||
"server_promo_groups_table": False,
|
||||
"users_promo_group_column": False,
|
||||
"promo_groups_period_discounts_column": False,
|
||||
"promo_groups_auto_assign_column": False,
|
||||
"users_auto_promo_group_assigned_column": False,
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -1196,9 +1964,13 @@ async def check_migration_status():
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
|
||||
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
|
||||
status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks')
|
||||
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
@@ -1230,7 +2002,11 @@ async def check_migration_status():
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей"
|
||||
"server_promo_groups_table": "Связи серверов и промогрупп",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей",
|
||||
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
|
||||
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
|
||||
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+216
@@ -0,0 +1,216 @@
|
||||
"""Async client for PayPalych (Pal24) API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24APIError(Exception):
|
||||
"""Base error for Pal24 API operations."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Pal24Response:
|
||||
"""Wrapper for Pal24 API responses."""
|
||||
|
||||
success: bool
|
||||
data: Dict[str, Any]
|
||||
status: int
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
|
||||
success = bool(payload.get("success", status < 400))
|
||||
return cls(success=success, data=payload, status=status)
|
||||
|
||||
def raise_for_status(self, endpoint: str) -> None:
|
||||
if not self.success:
|
||||
detail = self.data.get("message") or self.data.get("error")
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
|
||||
)
|
||||
|
||||
|
||||
class Pal24Client:
|
||||
"""Async client implementing PayPalych API methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_token: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
self.api_token = api_token or settings.PAL24_API_TOKEN
|
||||
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
|
||||
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
|
||||
|
||||
if not self.api_token:
|
||||
logger.warning("Pal24Client initialized without API token")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_token and self.base_url)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Response:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 client is not configured")
|
||||
|
||||
url = f"{self.base_url}{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_payload,
|
||||
params=params,
|
||||
) as response:
|
||||
status = response.status
|
||||
try:
|
||||
payload = await response.json(content_type=None)
|
||||
except aiohttp.ContentTypeError:
|
||||
text_body = await response.text()
|
||||
logger.error(
|
||||
"Pal24 API returned non-JSON response for %s: %s",
|
||||
endpoint,
|
||||
text_body,
|
||||
)
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API returned non-JSON response: {text_body}"
|
||||
) from None
|
||||
|
||||
result = Pal24Response.from_payload(payload, status)
|
||||
if status >= 400 or not result.success:
|
||||
logger.error(
|
||||
"Pal24 API error %s %s: %s",
|
||||
status,
|
||||
endpoint,
|
||||
payload,
|
||||
)
|
||||
result.raise_for_status(endpoint)
|
||||
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("Pal24 API client error for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(str(error)) from error
|
||||
|
||||
# API methods -----------------------------------------------------------------
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount: Decimal,
|
||||
shop_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
currency_in: str = "RUB",
|
||||
type_: str = "normal",
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": str(amount),
|
||||
"shop_id": shop_id,
|
||||
"currency_in": currency_in,
|
||||
"type": type_,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if description:
|
||||
payload["description"] = description
|
||||
|
||||
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
response = await self._request("POST", "bill/create", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/status", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
|
||||
payload = {"id": bill_id, "active": 1 if active else 0}
|
||||
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def search_payments(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/status", params={"id": payment_id})
|
||||
return response.data
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "merchant/balance")
|
||||
return response.data
|
||||
|
||||
async def search_bills(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/payments", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
# Helpers ---------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
|
||||
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
|
||||
if not token:
|
||||
raise Pal24APIError("Pal24 signature token is not configured")
|
||||
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
|
||||
return hashlib.md5(raw).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def verify_signature(
|
||||
out_sum: str,
|
||||
inv_id: str,
|
||||
signature: str,
|
||||
api_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
|
||||
except Pal24APIError:
|
||||
logger.error("Pal24 signature verification failed: missing token")
|
||||
return False
|
||||
return expected == signature.upper()
|
||||
|
||||
@staticmethod
|
||||
def normalize_amount(amount_kopeks: int) -> Decimal:
|
||||
try:
|
||||
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, TypeError) as error:
|
||||
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
|
||||
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> Dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode("utf-8")
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({"status": "ok"}), 200
|
||||
return jsonify({"status": "error", "reason": "not_processed"}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"enabled": settings.is_pal24_enabled(),
|
||||
}), 200
|
||||
|
||||
@app.route("/pal24/health", methods=["GET"])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"path": settings.PAL24_WEBHOOK_PATH,
|
||||
}), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Optional[Any] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning("Pal24 webhook server уже запущен")
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host="0.0.0.0",
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
"Pal24 webhook сервер запущен на %s:%s%s",
|
||||
"0.0.0.0",
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info("Останавливаем Pal24 webhook сервер")
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
|
||||
Vendored
+88
-4
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
@@ -7,6 +9,8 @@ from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,18 +29,25 @@ class WebhookServer:
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -62,6 +73,10 @@ class WebhookServer:
|
||||
|
||||
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(
|
||||
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
|
||||
@@ -89,12 +104,81 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await payment_service.process_mulenpay_callback(db, payload)
|
||||
if success:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
signature = request.headers.get('X-MulenPay-Signature')
|
||||
if signature:
|
||||
expected_signature = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
if authorization_header and authorization_header.startswith('Bearer '):
|
||||
token = authorization_header.split(' ', 1)[1].strip()
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный Bearer токен Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
+127
-1
@@ -10,14 +10,18 @@ from app.keyboards.admin import (
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_support_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -113,6 +117,115 @@ async def show_communications_submenu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# Moderators have access only to tickets and not to settings
|
||||
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
|
||||
from app.keyboards.admin import get_admin_support_submenu_keyboard
|
||||
kb = get_admin_support_submenu_keyboard(db_user.language)
|
||||
if is_moderator_only:
|
||||
# Rebuild keyboard to include only tickets and back to main menu
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
|
||||
reply_markup=kb,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
async def show_moderator_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_audit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
per_page = 10
|
||||
total = await TicketCRUD.count_support_audit(db)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = ["🧾 <b>Аудит модераторов</b>", ""]
|
||||
if not logs:
|
||||
lines.append("Пока пусто")
|
||||
else:
|
||||
for log in logs:
|
||||
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
|
||||
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
|
||||
action_map = {
|
||||
'close_ticket': 'Закрытие тикета',
|
||||
'block_user_timed': 'Блокировка (время)',
|
||||
'block_user_perm': 'Блокировка (навсегда)',
|
||||
'unblock_user': 'Снятие блока',
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
|
||||
details = log.details or {}
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
nav_row = []
|
||||
if total_pages > 1:
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
|
||||
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
|
||||
|
||||
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
@@ -142,7 +255,7 @@ async def show_system_submenu(
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Обновления, резервные копии и системные операции:",
|
||||
"Отчеты, обновления, резервные копии и системные операции:",
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -285,6 +398,15 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_submenu,
|
||||
F.data == "admin_submenu_support"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_support_audit,
|
||||
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
@@ -294,6 +416,10 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_moderator_panel,
|
||||
F.data == "moderator_panel"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
@@ -12,11 +14,334 @@ from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.states import AdminStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
def _format_toggle(enabled: bool) -> str:
|
||||
return "🟢 Вкл" if enabled else "🔴 Выкл"
|
||||
|
||||
|
||||
def _build_notification_settings_view(language: str):
|
||||
texts = get_texts(language)
|
||||
config = NotificationSettingsService.get_config()
|
||||
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
|
||||
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
|
||||
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
|
||||
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
|
||||
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
|
||||
|
||||
summary_text = (
|
||||
"🔔 <b>Уведомления пользователям</b>\n\n"
|
||||
f"• 1 час после триала: {trial_1h_status}\n"
|
||||
f"• 24 часа после триала: {trial_24h_status}\n"
|
||||
f"• 1 день после истечения: {expired_1d_status}\n"
|
||||
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
|
||||
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
|
||||
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
|
||||
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
|
||||
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
|
||||
[InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
|
||||
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
|
||||
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
|
||||
])
|
||||
|
||||
return summary_text, keyboard
|
||||
|
||||
|
||||
def _build_notification_preview_message(language: str, notification_type: str):
|
||||
texts = get_texts(language)
|
||||
now = datetime.now()
|
||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
header = "🧪 <b>Тестовое уведомление мониторинга</b>\n\n"
|
||||
|
||||
if notification_type == "trial_inactive_1h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
|
||||
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_inactive_24h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_24H",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_1d":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\n\n"
|
||||
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
price=price_30_days,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=3,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_nd":
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported notification type: {notification_type}")
|
||||
|
||||
footer = "\n\n<i>Сообщение отправлено только вам для проверки оформления.</i>"
|
||||
return header + message + footer, keyboard
|
||||
|
||||
|
||||
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
|
||||
message, keyboard = _build_notification_preview_message(language, notification_type)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def _render_notification_settings(callback: CallbackQuery) -> None:
|
||||
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
async def _render_notification_settings_for_state(
|
||||
bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
language: str,
|
||||
business_connection_id: str | None = None,
|
||||
) -> None:
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
|
||||
edit_kwargs = {
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
edit_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(**edit_kwargs)
|
||||
except TelegramBadRequest as exc:
|
||||
if "no text in the message to edit" in (exc.message or "").lower():
|
||||
caption_kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"caption": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
caption_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
await bot.edit_message_caption(**caption_kwargs)
|
||||
else:
|
||||
raise
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
@@ -52,6 +377,265 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_settings")
|
||||
@admin_required
|
||||
async def admin_monitoring_settings(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
text = (
|
||||
"⚙️ <b>Настройки мониторинга</b>\n\n"
|
||||
f"🔔 <b>Уведомления пользователям:</b> {global_status}\n"
|
||||
f"• Скидка 2-3 дня: {second_percent}%\n"
|
||||
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
|
||||
"Выберите раздел для настройки."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
|
||||
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_settings")
|
||||
@admin_required
|
||||
async def admin_notify_settings(callback: CallbackQuery):
|
||||
try:
|
||||
await _render_notification_settings(callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
|
||||
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
|
||||
@admin_required
|
||||
async def toggle_trial_1h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
|
||||
@admin_required
|
||||
async def preview_trial_1h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 1h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
|
||||
@admin_required
|
||||
async def toggle_trial_24h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
|
||||
@admin_required
|
||||
async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 24h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
|
||||
@admin_required
|
||||
async def toggle_expired_1d_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_expired_1d_enabled()
|
||||
NotificationSettingsService.set_expired_1d_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
|
||||
@admin_required
|
||||
async def preview_expired_1d_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send expired 1d preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
|
||||
@admin_required
|
||||
async def toggle_second_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_second_wave_enabled()
|
||||
NotificationSettingsService.set_second_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
|
||||
@admin_required
|
||||
async def preview_second_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send second wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
|
||||
@admin_required
|
||||
async def toggle_third_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_third_wave_enabled()
|
||||
NotificationSettingsService.set_third_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
|
||||
@admin_required
|
||||
async def preview_third_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send third wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_all")
|
||||
@admin_required
|
||||
async def preview_all_notifications(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
chat_id = callback.from_user.id
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
]:
|
||||
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
|
||||
await callback.answer("✅ Все тестовые уведомления отправлены")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send all notification previews: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
|
||||
|
||||
|
||||
async def _start_notification_value_edit(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
setting_key: str,
|
||||
field: str,
|
||||
prompt_key: str,
|
||||
default_prompt: str,
|
||||
):
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await state.set_state(AdminStates.editing_notification_value)
|
||||
await state.update_data(
|
||||
notification_setting_key=setting_key,
|
||||
notification_setting_field=field,
|
||||
settings_message_chat=callback.message.chat.id,
|
||||
settings_message_id=callback.message.message_id,
|
||||
settings_business_connection_id=(
|
||||
str(getattr(callback.message, "business_connection_id", None))
|
||||
if getattr(callback.message, "business_connection_id", None) is not None
|
||||
else None
|
||||
),
|
||||
settings_language=language,
|
||||
)
|
||||
texts = get_texts(language)
|
||||
await callback.answer()
|
||||
await callback.message.answer(texts.get(prompt_key, default_prompt))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
|
||||
@admin_required
|
||||
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT",
|
||||
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
|
||||
@admin_required
|
||||
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
|
||||
@admin_required
|
||||
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT",
|
||||
"Введите новый процент скидки для позднего предложения (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
|
||||
@admin_required
|
||||
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
|
||||
@admin_required
|
||||
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"trigger",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS",
|
||||
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
@@ -366,5 +950,74 @@ async def monitoring_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(AdminStates.editing_notification_value)
|
||||
async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await state.clear()
|
||||
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
|
||||
return
|
||||
|
||||
raw_value = (message.text or "").strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
|
||||
return
|
||||
|
||||
key = data.get("notification_setting_key")
|
||||
field = data.get("notification_setting_field")
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
elif key == "expired_second_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_second_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_third_wave_discount_percent(value)
|
||||
elif key == "expired_third_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_third_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
success = NotificationSettingsService.set_third_wave_trigger_days(value)
|
||||
|
||||
if not success:
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
|
||||
return
|
||||
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK", "⬅️ Назад"),
|
||||
callback_data="admin_mon_notify_settings",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
chat_id = data.get("settings_message_chat")
|
||||
message_id = data.get("settings_message_id")
|
||||
business_connection_id = data.get("settings_business_connection_id")
|
||||
if chat_id and message_id:
|
||||
await _render_notification_settings_for_state(
|
||||
message.bot,
|
||||
chat_id,
|
||||
message_id,
|
||||
language,
|
||||
business_connection_id=business_connection_id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import (
|
||||
get_promo_groups_with_counts,
|
||||
get_promo_group_by_id,
|
||||
@@ -22,7 +24,7 @@ from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
)
|
||||
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -37,6 +39,336 @@ def _format_discount_line(texts, group) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]:
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
|
||||
discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
|
||||
|
||||
if discounts:
|
||||
return dict(sorted(discounts.items()))
|
||||
|
||||
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
|
||||
try:
|
||||
base_discounts = settings.get_base_promo_group_period_discounts()
|
||||
normalized = _normalize_periods_dict(base_discounts)
|
||||
return dict(sorted(normalized.items()))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list:
|
||||
discounts = _collect_period_discounts(group)
|
||||
|
||||
if not discounts:
|
||||
return []
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
|
||||
"⏳ Скидки по периодам:",
|
||||
)
|
||||
|
||||
lines = [header]
|
||||
|
||||
for period_days, percent in discounts.items():
|
||||
period_display = format_period_description(period_days, language)
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format(
|
||||
period=period_display,
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _format_period_discounts_value(discounts: Dict[int, int]) -> str:
|
||||
if not discounts:
|
||||
return "0"
|
||||
|
||||
return ", ".join(
|
||||
f"{period}:{percent}"
|
||||
for period, percent in sorted(discounts.items())
|
||||
)
|
||||
|
||||
|
||||
def _parse_period_discounts_input(value: str) -> Dict[int, int]:
|
||||
cleaned = (value or "").strip()
|
||||
|
||||
if not cleaned or cleaned in {"0", "-"}:
|
||||
return {}
|
||||
|
||||
cleaned = cleaned.replace(";", ",").replace("\n", ",")
|
||||
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
|
||||
|
||||
if not parts:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
|
||||
for part in parts:
|
||||
if ":" not in part:
|
||||
raise ValueError
|
||||
|
||||
period_raw, percent_raw = part.split(":", 1)
|
||||
|
||||
period = int(period_raw.strip())
|
||||
percent = int(percent_raw.strip())
|
||||
|
||||
if period <= 0:
|
||||
raise ValueError
|
||||
|
||||
discounts[period] = max(0, min(100, percent))
|
||||
|
||||
return discounts
|
||||
|
||||
|
||||
async def _prompt_for_period_discounts(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
*,
|
||||
current_value: Optional[str] = None,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
prompt_text = texts.t(prompt_key, default_text)
|
||||
|
||||
if current_value is not None:
|
||||
try:
|
||||
prompt_text = prompt_text.format(current=current_value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
await message.answer(prompt_text)
|
||||
|
||||
|
||||
def _format_rubles(amount_kopeks: int) -> str:
|
||||
if amount_kopeks <= 0:
|
||||
return "0"
|
||||
|
||||
rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
if rubles == rubles.to_integral_value():
|
||||
formatted = f"{rubles:,.0f}"
|
||||
else:
|
||||
formatted = f"{rubles:,.2f}"
|
||||
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
|
||||
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
|
||||
|
||||
if threshold <= 0:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED",
|
||||
"Автовыдача по суммарным тратам: отключена",
|
||||
)
|
||||
|
||||
amount = _format_rubles(threshold)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE",
|
||||
"Автовыдача по суммарным тратам: от {amount} ₽",
|
||||
).format(amount=amount)
|
||||
|
||||
|
||||
def _format_auto_assign_value(value_kopeks: Optional[int]) -> str:
|
||||
if not value_kopeks or value_kopeks <= 0:
|
||||
return "0"
|
||||
|
||||
rubles = Decimal(value_kopeks) / Decimal(100)
|
||||
quantized = (
|
||||
rubles.quantize(Decimal("1"))
|
||||
if rubles == rubles.to_integral_value()
|
||||
else rubles.quantize(Decimal("0.01"))
|
||||
)
|
||||
return str(quantized)
|
||||
|
||||
|
||||
def _parse_auto_assign_threshold_input(value: str) -> int:
|
||||
cleaned = (value or "").strip()
|
||||
|
||||
if not cleaned or cleaned in {"0", "-", "off", "нет"}:
|
||||
return 0
|
||||
|
||||
normalized = cleaned.replace(" ", "").replace(",", ".")
|
||||
|
||||
try:
|
||||
amount = Decimal(normalized)
|
||||
except InvalidOperation:
|
||||
raise ValueError
|
||||
|
||||
if amount < 0:
|
||||
raise ValueError
|
||||
|
||||
kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
return max(0, kopeks)
|
||||
|
||||
|
||||
async def _prompt_for_auto_assign_threshold(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
*,
|
||||
current_value: Optional[str] = None,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
prompt_text = texts.t(prompt_key, default_text)
|
||||
|
||||
if current_value is not None:
|
||||
try:
|
||||
prompt_text = prompt_text.format(current=current_value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
await message.answer(prompt_text)
|
||||
|
||||
|
||||
def _build_edit_menu_content(
|
||||
texts,
|
||||
group: PromoGroup,
|
||||
language: str,
|
||||
) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
header = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE",
|
||||
"✏️ Настройки промогруппы «{name}»",
|
||||
).format(name=group.name)
|
||||
|
||||
lines = [
|
||||
header,
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
]
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, language)
|
||||
lines.extend(period_lines)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT",
|
||||
"Выберите параметр для изменения:",
|
||||
)
|
||||
)
|
||||
|
||||
text = "\n".join(line for line in lines if line)
|
||||
|
||||
keyboard_rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME",
|
||||
"✏️ Изменить название",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_name",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC",
|
||||
"🌐 Скидка на трафик",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_traffic",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS",
|
||||
"🖥 Скидка на серверы",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_servers",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES",
|
||||
"📱 Скидка на устройства",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_devices",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS",
|
||||
"⏳ Скидки по периодам",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_periods",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN",
|
||||
"🤖 Автовыдача по тратам",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_auto",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"promo_group_manage_{group.id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
return text, keyboard
|
||||
|
||||
|
||||
def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup:
|
||||
return types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"promo_group_edit_{group_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _send_edit_menu_after_update(
|
||||
message: types.Message,
|
||||
texts,
|
||||
group: PromoGroup,
|
||||
language: str,
|
||||
success_message: Optional[str] = None,
|
||||
):
|
||||
menu_text, keyboard = _build_edit_menu_content(texts, group, language)
|
||||
parts = [part for part in [success_message, menu_text] if part]
|
||||
|
||||
await message.answer(
|
||||
"\n\n".join(parts),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_groups_menu(
|
||||
@@ -64,17 +396,21 @@ async def show_promo_groups_menu(
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
"",
|
||||
]
|
||||
)
|
||||
group_lines = [
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
group_lines.append("")
|
||||
|
||||
lines.extend(group_lines)
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
|
||||
@@ -127,25 +463,31 @@ async def show_promo_group_details(
|
||||
member_count = await count_promo_group_members(db, group.id)
|
||||
|
||||
default_note = (
|
||||
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
|
||||
texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
|
||||
text = "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
default_note,
|
||||
]
|
||||
)
|
||||
lines = [
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
lines.extend(period_lines)
|
||||
|
||||
if default_note:
|
||||
lines.append(default_note)
|
||||
|
||||
text = "\n".join(line for line in lines if line)
|
||||
|
||||
keyboard_rows = []
|
||||
if member_count > 0:
|
||||
@@ -299,13 +641,81 @@ async def process_create_group_devices(
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_devices=devices_discount)
|
||||
await state.set_state(AdminStates.creating_promo_group_period_discount)
|
||||
|
||||
await _prompt_for_period_discounts(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT",
|
||||
"Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_period_discounts(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
period_discounts = _parse_period_discounts_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
|
||||
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_group_period_discounts=period_discounts)
|
||||
await state.set_state(AdminStates.creating_promo_group_auto_assign)
|
||||
|
||||
await _prompt_for_auto_assign_threshold(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT",
|
||||
"Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_auto_assign(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
|
||||
"Введите неотрицательное число в рублях или 0 для отключения.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
device_discount_percent=data["new_group_devices"],
|
||||
period_discounts=data.get("new_group_period_discounts"),
|
||||
auto_assign_total_spent_kopeks=auto_assign_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать промогруппу: {e}")
|
||||
@@ -347,73 +757,190 @@ async def start_edit_promo_group(
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.editing_promo_group_name)
|
||||
await state.update_data(edit_group_id=group.id, language=db_user.language)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
text, keyboard = _build_edit_menu_content(texts, group, db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
|
||||
]
|
||||
),
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_edit_group_name(message: types.Message, state: FSMContext):
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_promo_group_field(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 6:
|
||||
await callback.answer("❌ Неверная команда", show_alert=True)
|
||||
return
|
||||
|
||||
group_id = int(parts[4])
|
||||
field = parts[5]
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_id=group.id, language=db_user.language)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
reply_markup = _get_edit_prompt_keyboard(group.id, texts)
|
||||
|
||||
if field == "name":
|
||||
await state.set_state(AdminStates.editing_promo_group_name)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name)
|
||||
elif field == "traffic":
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
|
||||
"Введите новую скидку на трафик (текущее значение: {current}%):",
|
||||
).format(current=group.traffic_discount_percent)
|
||||
elif field == "servers":
|
||||
await state.set_state(AdminStates.editing_promo_group_server_discount)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
|
||||
"Введите новую скидку на серверы (текущее значение: {current}%):",
|
||||
).format(current=group.server_discount_percent)
|
||||
elif field == "devices":
|
||||
await state.set_state(AdminStates.editing_promo_group_device_discount)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
|
||||
"Введите новую скидку на устройства (текущее значение: {current}%):",
|
||||
).format(current=group.device_discount_percent)
|
||||
elif field == "periods":
|
||||
await state.set_state(AdminStates.editing_promo_group_period_discount)
|
||||
current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
|
||||
"Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
|
||||
).format(current=_format_period_discounts_value(current_discounts))
|
||||
elif field == "auto":
|
||||
await state.set_state(AdminStates.editing_promo_group_auto_assign)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT",
|
||||
"Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
|
||||
).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks))
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный параметр", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(prompt, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_name(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_name=name)
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, name=name)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
|
||||
"Введите новую скидку на трафик (0-100):",
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_traffic(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_traffic=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, traffic_discount_percent=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
|
||||
"Введите новую скидку на серверы (0-100):",
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_servers(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_servers=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, server_discount_percent=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
|
||||
"Введите новую скидку на устройства (0-100):",
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@@ -434,24 +961,105 @@ async def process_edit_group_devices(
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data["edit_group_id"])
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_promo_group(
|
||||
db,
|
||||
group = await update_promo_group(db, group, device_discount_percent=devices_discount)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
name=data["edit_group_name"],
|
||||
traffic_discount_percent=data["edit_group_traffic"],
|
||||
server_discount_percent=data["edit_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_period_discounts(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
period_discounts = _parse_period_discounts_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
|
||||
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, period_discounts=period_discounts)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_auto_assign(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
|
||||
"Введите неотрицательное число в рублях или 0 для отключения.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
auto_assign_total_spent_kopeks=auto_assign_kopeks,
|
||||
)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@@ -588,7 +1196,14 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
|
||||
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
|
||||
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
|
||||
dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
|
||||
dp.callback_query.register(
|
||||
prompt_edit_promo_group_field,
|
||||
F.data.startswith("promo_group_edit_field_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_promo_group,
|
||||
F.data.regexp(r"^promo_group_edit_\d+$"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
request_delete_promo_group,
|
||||
F.data.startswith("promo_group_delete_")
|
||||
@@ -616,6 +1231,14 @@ def register_handlers(dp: Dispatcher):
|
||||
process_create_group_devices,
|
||||
AdminStates.creating_promo_group_device_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_period_discounts,
|
||||
AdminStates.creating_promo_group_period_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_auto_assign,
|
||||
AdminStates.creating_promo_group_auto_assign,
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
@@ -630,3 +1253,11 @@ def register_handlers(dp: Dispatcher):
|
||||
process_edit_group_devices,
|
||||
AdminStates.editing_promo_group_device_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_period_discounts,
|
||||
AdminStates.editing_promo_group_period_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_auto_assign,
|
||||
AdminStates.editing_promo_group_auto_assign,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_report_result_keyboard,
|
||||
get_admin_reports_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.reporting_service import (
|
||||
ReportPeriod,
|
||||
ReportingServiceError,
|
||||
reporting_service,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_reports_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Отчеты</b>\n\n"
|
||||
"Выберите период, чтобы отправить отчет в админский топик.",
|
||||
reply_markup=get_admin_reports_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_daily_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_weekly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_monthly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
|
||||
|
||||
|
||||
async def _send_report(
|
||||
callback: types.CallbackQuery,
|
||||
period: ReportPeriod,
|
||||
language: str,
|
||||
) -> None:
|
||||
try:
|
||||
report_text = await reporting_service.send_report(period, send_to_topic=True)
|
||||
except ReportingServiceError as exc:
|
||||
logger.warning("Не удалось отправить отчет: %s", exc)
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
|
||||
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
report_text,
|
||||
reply_markup=get_admin_report_result_keyboard(language),
|
||||
)
|
||||
await callback.answer("Отчет отправлен в топик")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_report_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
|
||||
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
|
||||
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
|
||||
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
|
||||
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
|
||||
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
|
||||
|
||||
+286
-114
@@ -6,10 +6,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
update_server_squad,
|
||||
delete_server_squad,
|
||||
sync_with_remnawave,
|
||||
get_server_statistics,
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
@@ -17,6 +24,111 @@ from app.utils.cache import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_server_edit_view(server):
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
promo_groups_text = (
|
||||
", ".join(sorted(pg.name for pg in server.allowed_promo_groups))
|
||||
if server.allowed_promo_groups
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"),
|
||||
],
|
||||
]
|
||||
|
||||
return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids):
|
||||
keyboard = []
|
||||
for group in promo_groups:
|
||||
emoji = "✅" if group["id"] in selected_ids else "⚪"
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {group['name']}",
|
||||
callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
@@ -166,7 +278,7 @@ async def sync_servers_with_remnawave(
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
@@ -216,63 +328,16 @@ async def show_server_edit_menu(
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -296,65 +361,18 @@ async def toggle_server_availability(
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -422,7 +440,7 @@ async def process_server_price_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
@@ -497,7 +515,7 @@ async def process_server_name_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
@@ -570,7 +588,7 @@ async def delete_server_execute(
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
@@ -701,7 +719,7 @@ async def process_server_country_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
@@ -847,11 +865,12 @@ async def process_server_description_edit(
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -862,6 +881,147 @@ async def process_server_description_edit(
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups_data = await get_promo_groups_with_counts(db)
|
||||
promo_groups = [
|
||||
{"id": group.id, "name": group.name, "is_default": group.is_default}
|
||||
for group, _ in promo_groups_data
|
||||
]
|
||||
|
||||
if not promo_groups:
|
||||
await callback.answer("❌ Не найдены промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if not selected_ids:
|
||||
default_group = next((pg for pg in promo_groups if pg["is_default"]), None)
|
||||
if default_group:
|
||||
selected_ids.add(default_group["id"])
|
||||
|
||||
await state.set_state(AdminStates.editing_server_promo_groups)
|
||||
await state.set_data(
|
||||
{
|
||||
"server_id": server_id,
|
||||
"promo_groups": promo_groups,
|
||||
"selected_promo_groups": list(selected_ids),
|
||||
"server_name": server.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
text = (
|
||||
"🎯 <b>Настройка промогрупп</b>\n\n"
|
||||
f"Сервер: <b>{server.display_name}</b>\n\n"
|
||||
"Выберите промогруппы, которым будет доступен этот сервер.\n"
|
||||
"Должна быть выбрана минимум одна промогруппа."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
server_id = int(parts[4])
|
||||
group_id = int(parts[5])
|
||||
|
||||
data = await state.get_data()
|
||||
if not data or data.get("server_id") != server_id:
|
||||
await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True)
|
||||
return
|
||||
|
||||
selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", []))
|
||||
promo_groups = data.get("promo_groups", [])
|
||||
|
||||
if group_id in selected:
|
||||
if len(selected) == 1:
|
||||
await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True)
|
||||
return
|
||||
selected.remove(group_id)
|
||||
message = "Промогруппа отключена"
|
||||
else:
|
||||
selected.add(group_id)
|
||||
message = "Промогруппа добавлена"
|
||||
|
||||
await state.update_data(selected_promo_groups=list(selected))
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected)
|
||||
)
|
||||
await callback.answer(message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_server_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await callback.answer("⚠️ Нет данных для сохранения", show_alert=True)
|
||||
return
|
||||
|
||||
server_id = data.get("server_id")
|
||||
selected = data.get("selected_promo_groups", [])
|
||||
|
||||
if not selected:
|
||||
await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
server = await update_server_squad_promo_groups(db, server_id, selected)
|
||||
except ValueError as exc:
|
||||
await callback.answer(f"❌ {exc}", show_alert=True)
|
||||
return
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await state.clear()
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("✅ Промогруппы обновлены!")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
@@ -934,12 +1094,22 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(
|
||||
show_server_edit_menu,
|
||||
F.data.startswith("admin_server_edit_")
|
||||
& ~F.data.contains("name")
|
||||
& ~F.data.contains("price")
|
||||
& ~F.data.contains("country")
|
||||
& ~F.data.contains("limit")
|
||||
& ~F.data.contains("desc")
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
@@ -947,7 +1117,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
|
||||
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
@@ -20,6 +21,10 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
sla_enabled = SupportSettingsService.get_sla_enabled()
|
||||
sla_minutes = SupportSettingsService.get_sla_minutes()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
@@ -40,8 +45,53 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
types.InlineKeyboardButton(
|
||||
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
|
||||
callback_data="admin_support_toggle_admin_notifications"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
|
||||
callback_data="admin_support_toggle_user_notifications"
|
||||
)
|
||||
])
|
||||
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
|
||||
callback_data="admin_support_toggle_sla"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"⏳ Время SLA: {sla_minutes} мин",
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
|
||||
# Moderators
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🧑⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
@@ -78,6 +128,175 @@ async def toggle_support_menu(
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_sla_enabled()
|
||||
SupportSettingsService.set_sla_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
|
||||
# we'll manage separate state below
|
||||
|
||||
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
minutes = int(text)
|
||||
if minutes <= 0 or minutes > 1440:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректное число минут (1-1440)")
|
||||
return
|
||||
SupportSettingsService.set_sla_minutes(minutes)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректный Telegram ID (число)")
|
||||
return
|
||||
if SupportSettingsService.add_moderator(tid):
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
|
||||
else:
|
||||
await message.answer("❌ Не удалось сохранить")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
# We'll reuse the same state; next message will decide action via flag
|
||||
await state.update_data(action="remove_moderator")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
action = data.get("action", "add")
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректный Telegram ID (число)")
|
||||
return
|
||||
ok = False
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
|
||||
else:
|
||||
ok = SupportSettingsService.add_moderator(tid)
|
||||
msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
if not moderators:
|
||||
await callback.answer("Список пуст", show_alert=True)
|
||||
return
|
||||
text = "🧑⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
@@ -177,9 +396,17 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
await callback.answer("Текст отправлен ниже")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Allow admins and moderators to delete informational notifications
|
||||
try:
|
||||
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
except Exception:
|
||||
may_delete = False
|
||||
if not may_delete:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
@@ -196,6 +423,15 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
|
||||
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
|
||||
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
|
||||
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
|
||||
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
|
||||
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
|
||||
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
|
||||
|
||||
|
||||
|
||||
+253
-39
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
@@ -18,6 +19,7 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pagination import paginate_list, get_pagination_info
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.config import settings
|
||||
from app.utils.cache import RateLimitCache
|
||||
|
||||
@@ -33,6 +35,11 @@ async def show_admin_tickets(
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показать все тикеты для админов"""
|
||||
# permission gate: admin or active moderator only
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Определяем текущую страницу и scope
|
||||
@@ -59,6 +66,8 @@ async def show_admin_tickets(
|
||||
# total count for proper pagination
|
||||
total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses)
|
||||
total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1
|
||||
if current_page < 1:
|
||||
current_page = 1
|
||||
if current_page > total_pages:
|
||||
current_page = total_pages
|
||||
offset = (current_page - 1) * page_size
|
||||
@@ -81,9 +90,33 @@ async def show_admin_tickets(
|
||||
})
|
||||
|
||||
# Итоговые страницы уже посчитаны выше
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"),
|
||||
reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope)
|
||||
header_text = (
|
||||
texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:")
|
||||
if scope == "open"
|
||||
else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:")
|
||||
)
|
||||
# Determine proper back target for moderators
|
||||
back_cb = "admin_submenu_support"
|
||||
try:
|
||||
if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id):
|
||||
back_cb = "moderator_panel"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
keyboard = get_admin_tickets_keyboard(
|
||||
ticket_data,
|
||||
current_page=current_page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
scope=scope,
|
||||
back_callback=back_cb,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=header_text,
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -96,6 +129,11 @@ async def view_admin_ticket(
|
||||
ticket_id: Optional[int] = None
|
||||
):
|
||||
"""Показать детали тикета для админа"""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
|
||||
if ticket_id is None:
|
||||
try:
|
||||
ticket_id = int((callback.data or "").split("_")[-1])
|
||||
@@ -158,9 +196,10 @@ async def view_admin_ticket(
|
||||
# Добавим кнопку "Вложения", если есть фото
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
|
||||
keyboard = get_admin_ticket_view_keyboard(
|
||||
ticket_id,
|
||||
ticket.is_closed,
|
||||
db_user.language
|
||||
ticket_id,
|
||||
ticket.is_closed,
|
||||
db_user.language,
|
||||
is_user_blocked=ticket.is_user_reply_blocked
|
||||
)
|
||||
if has_photos:
|
||||
try:
|
||||
@@ -168,24 +207,20 @@ async def view_admin_ticket(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Сначала пробуем отредактировать; если не вышло — удалим и отправим новое
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception:
|
||||
# Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=ticket_text,
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
# сохраняем id для дальнейших действий (ответ/статусы)
|
||||
if state is not None:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
# сохраняем id для дальнейших действий (ответ/статусы)
|
||||
if state:
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -195,6 +230,10 @@ async def reply_to_admin_ticket(
|
||||
db_user: User
|
||||
):
|
||||
"""Начать ответ на тикет от админа"""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
ticket_id = int(callback.data.replace("admin_reply_ticket_", ""))
|
||||
|
||||
await state.update_data(ticket_id=ticket_id, reply_mode=True)
|
||||
@@ -214,6 +253,11 @@ async def handle_admin_ticket_reply(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.ACCESS_DENIED)
|
||||
await state.clear()
|
||||
return
|
||||
# Проверяем, что пользователь в правильном состоянии
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_reply:
|
||||
@@ -388,17 +432,42 @@ async def close_admin_ticket(
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть тикет админом"""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
ticket_id = int(callback.data.replace("admin_close_ticket_", ""))
|
||||
|
||||
try:
|
||||
success = await TicketCRUD.close_ticket(db, ticket_id)
|
||||
|
||||
if success:
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_mod,
|
||||
action="close_ticket",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
|
||||
show_alert=True
|
||||
)
|
||||
# Notify with deletable inline message
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True)
|
||||
|
||||
# Обновляем inline-клавиатуру в текущем сообщении без кнопок действий
|
||||
await callback.message.edit_reply_markup(
|
||||
@@ -426,6 +495,10 @@ async def cancel_admin_ticket_reply(
|
||||
db_user: User
|
||||
):
|
||||
"""Отменить ответ админа на тикет"""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -448,13 +521,22 @@ async def block_user_in_ticket(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
ticket_id = int(callback.data.replace("admin_block_user_ticket_", ""))
|
||||
texts = get_texts(db_user.language)
|
||||
# Save original ticket message ids to update it after blocking without reopening
|
||||
try:
|
||||
await state.update_data(origin_chat_id=callback.message.chat.id, origin_message_id=callback.message.message_id)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.edit_text(
|
||||
texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"),
|
||||
callback_data="cancel_admin_ticket_reply"
|
||||
)]
|
||||
])
|
||||
@@ -470,6 +552,12 @@ async def handle_admin_block_duration_input(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# permission gate for message flow
|
||||
if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.ACCESS_DENIED)
|
||||
await state.clear()
|
||||
return
|
||||
# Проверяем состояние
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_block_duration:
|
||||
@@ -482,6 +570,8 @@ async def handle_admin_block_duration_input(
|
||||
|
||||
data = await state.get_data()
|
||||
ticket_id = data.get("ticket_id")
|
||||
origin_chat_id = data.get("origin_chat_id")
|
||||
origin_message_id = data.get("origin_message_id")
|
||||
try:
|
||||
minutes = int(reply_text)
|
||||
minutes = max(1, min(60*24*365, minutes)) # максимум 1 год
|
||||
@@ -505,15 +595,76 @@ async def handle_admin_block_duration_input(
|
||||
|
||||
until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if ok:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
if not ok:
|
||||
await message.answer("❌ Ошибка блокировки")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"✅ Блокировка установлена. Откройте тикет заново для обновления состояния.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]])
|
||||
)
|
||||
return
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id))
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=message.from_user.id,
|
||||
is_moderator=is_mod,
|
||||
action="block_user_timed",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=ticket.user_id if ticket else None,
|
||||
details={"minutes": minutes}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Refresh original ticket card (caption/text and buttons) in place
|
||||
try:
|
||||
updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
|
||||
texts = get_texts(db_user.language)
|
||||
status_text = {
|
||||
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
|
||||
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
|
||||
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
|
||||
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
|
||||
}.get(updated.status, updated.status)
|
||||
user_name = updated.user.full_name if updated.user else "Unknown"
|
||||
ticket_text = f"🎫 Тикет #{updated.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"📝 Заголовок: {updated.title}\n"
|
||||
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
elif updated.user_reply_block_until:
|
||||
ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
if updated.messages:
|
||||
ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n"
|
||||
for msg in updated.messages:
|
||||
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
|
||||
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
|
||||
ticket_text += f"{msg.message_text}\n\n"
|
||||
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
|
||||
if has_photos:
|
||||
try:
|
||||
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")])
|
||||
except Exception:
|
||||
pass
|
||||
if origin_chat_id and origin_message_id:
|
||||
try:
|
||||
await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML")
|
||||
except Exception:
|
||||
try:
|
||||
await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML")
|
||||
except Exception:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
except Exception:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
finally:
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting block duration: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -522,19 +673,44 @@ async def handle_admin_block_duration_input(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def unblock_user_in_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Блок снят")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"✅ Блок снят",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
await callback.answer("✅ Блок снят")
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_mod,
|
||||
action="unblock_user",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await view_admin_ticket(callback, db_user, db, state)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
@@ -546,10 +722,37 @@ async def block_user_permanently(
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Пользователь заблокирован навсегда")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"✅ Пользователь заблокирован навсегда",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
await callback.answer("✅ Пользователь заблокирован")
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_mod,
|
||||
action="block_user_perm",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await view_admin_ticket(callback, db_user, db, state)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
@@ -558,6 +761,12 @@ async def block_user_permanently(
|
||||
async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
"""Уведомить пользователя о новом ответе в тикете"""
|
||||
try:
|
||||
# Respect runtime toggle for user ticket notifications
|
||||
try:
|
||||
if not SupportSettingsService.get_user_ticket_notifications_enabled():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
# Получаем тикет с пользователем
|
||||
@@ -655,6 +864,11 @@ def register_handlers(dp: Dispatcher):
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# permission gate for attachments view
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
ticket_id = int(callback.data.replace("admin_ticket_attachments_", ""))
|
||||
|
||||
+542
-3
@@ -332,6 +332,84 @@ async def start_yookassa_sbp_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_mulenpay_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="mulenpay")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через защищенную платформу PayPalych."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -523,10 +601,18 @@ async def process_topup_amount(
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -760,6 +846,241 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_mulenpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("payment_url")
|
||||
mulen_payment_id = payment_result.get("mulen_payment_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("link_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
link_url = payment_result.get("link_url")
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
|
||||
url=link_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через PayPalych’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -815,6 +1136,122 @@ async def check_yookassa_payment_status(
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
|
||||
elif payment.status in {"canceled", "error"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
message_text = "".join(message_lines)
|
||||
|
||||
if len(message_text) > 190:
|
||||
await callback.message.answer(message_text)
|
||||
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
|
||||
else:
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа PayPalych:\n\n",
|
||||
f"🆔 ID счета: {payment.bill_id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if payment.link_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer("".join(message_lines), show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1085,6 +1522,12 @@ async def handle_quick_amount_selection(
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
@@ -1096,6 +1539,77 @@ async def handle_quick_amount_selection(
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
_, method, amount_str = callback.data.split("|", 2)
|
||||
amount_kopeks = int(amount_str)
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
await callback.answer("❌ Некорректная сумма", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка быстрого пополнения: {error}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -1132,12 +1646,22 @@ def register_handlers(dp: Dispatcher):
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_mulenpay_payment,
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
@@ -1168,6 +1692,16 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
@@ -1178,3 +1712,8 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -120,5 +121,11 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
# Самый последний: ловим любые неизвестные текстовые сообщения
|
||||
dp.message.register(handle_unknown_message)
|
||||
# Исключаем специальные сервисные события (например, успешные платежи),
|
||||
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None)
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,6 +49,7 @@ async def show_main_menu(
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
@@ -120,6 +122,7 @@ async def handle_back_to_menu(
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
|
||||
+40
-4
@@ -591,7 +591,16 @@ async def complete_registration_from_callback(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -689,7 +698,16 @@ async def complete_registration_from_callback(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
@@ -797,7 +815,16 @@ async def complete_registration(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -895,7 +922,16 @@ async def complete_registration(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
|
||||
+556
-115
File diff suppressed because it is too large
Load Diff
+58
-46
@@ -374,12 +374,17 @@ async def show_my_tickets(
|
||||
except ValueError:
|
||||
current_page = 1
|
||||
|
||||
# Получаем тикеты пользователя (открытые/закрытые отдельно)
|
||||
all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100)
|
||||
open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value]
|
||||
closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value]
|
||||
|
||||
if not open_tickets and not closed_tickets:
|
||||
# Пагинация открытых тикетов из БД
|
||||
per_page = 10
|
||||
total_open = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value])
|
||||
total_pages = max(1, (total_open + per_page - 1) // per_page)
|
||||
current_page = max(1, min(current_page, total_pages))
|
||||
offset = (current_page - 1) * per_page
|
||||
open_tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], limit=per_page, offset=offset)
|
||||
|
||||
# Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых)
|
||||
has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0
|
||||
if not open_tickets and not has_closed_any:
|
||||
await callback.message.edit_text(
|
||||
texts.t("NO_TICKETS", "У вас пока нет тикетов."),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -400,32 +405,19 @@ async def show_my_tickets(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Открытые с пагинацией
|
||||
open_data = []
|
||||
for t in open_tickets:
|
||||
if t.status != TicketStatus.CLOSED.value:
|
||||
open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji})
|
||||
per_page = 10
|
||||
pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page)
|
||||
# Корректируем текущую страницу в допустимые границы
|
||||
current_page = max(1, min(current_page, pag["total_pages"]))
|
||||
start_index = (current_page - 1) * per_page
|
||||
end_index = start_index + per_page
|
||||
page_items = open_data[start_index:end_index]
|
||||
keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language)
|
||||
# Открытые с пагинацией (DB)
|
||||
open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets]
|
||||
keyboard = get_my_tickets_keyboard(open_data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_page_")
|
||||
# Добавим кнопку перехода к закрытым
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")])
|
||||
# Покажем список тикетов c логотипом, если режим включен
|
||||
if settings.ENABLE_LOGO_MODE and callback.message.photo:
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard)
|
||||
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -435,9 +427,18 @@ async def show_my_tickets_closed(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# Пагинация (при необходимости можно добавить аналогично open)
|
||||
tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10)
|
||||
if not tickets:
|
||||
# Пагинация закрытых
|
||||
current_page = 1
|
||||
data_str = callback.data
|
||||
if data_str.startswith("my_tickets_closed_page_"):
|
||||
try:
|
||||
current_page = int(data_str.replace("my_tickets_closed_page_", ""))
|
||||
except ValueError:
|
||||
current_page = 1
|
||||
|
||||
per_page = 10
|
||||
total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value])
|
||||
if total_closed == 0:
|
||||
await callback.message.edit_text(
|
||||
texts.t("NO_CLOSED_TICKETS", "Закрытых тикетов пока нет."),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -447,19 +448,20 @@ async def show_my_tickets_closed(
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
total_pages = max(1, (total_closed + per_page - 1) // per_page)
|
||||
current_page = max(1, min(current_page, total_pages))
|
||||
offset = (current_page - 1) * per_page
|
||||
tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset)
|
||||
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
|
||||
kb = get_my_tickets_keyboard(data, current_page=1, language=db_user.language)
|
||||
kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_")
|
||||
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")])
|
||||
if settings.ENABLE_LOGO_MODE and callback.message.photo:
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
|
||||
keyboard=kb,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
|
||||
keyboard=kb,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -758,7 +760,10 @@ async def handle_ticket_reply(
|
||||
if ticket.status == TicketStatus.CLOSED.value:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт.")
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]]
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -767,7 +772,10 @@ async def handle_ticket_reply(
|
||||
if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно.")
|
||||
texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно."),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]]
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -981,6 +989,10 @@ def register_handlers(dp: Dispatcher):
|
||||
show_my_tickets_closed,
|
||||
F.data == "my_tickets_closed"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_my_tickets_closed,
|
||||
F.data.startswith("my_tickets_closed_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
view_ticket,
|
||||
|
||||
+42
-4
@@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")],
|
||||
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
|
||||
@@ -61,15 +62,28 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
|
||||
InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
@@ -85,6 +99,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"),
|
||||
callback_data="admin_mon_settings"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
@@ -97,18 +117,36 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
|
||||
|
||||
def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"),
|
||||
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")],
|
||||
[InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")],
|
||||
[InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("REPORT_CLOSE", "❌ Закрыть"), callback_data="admin_close_report")]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
|
||||
+153
-84
@@ -67,6 +67,8 @@ def get_main_menu_keyboard(
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -85,13 +87,24 @@ def get_main_menu_keyboard(
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_url = getattr(subscription, "subscription_url", None)
|
||||
|
||||
def _fallback_connect_button() -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
if subscription_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription_url)
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([_fallback_connect_button()])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -100,19 +113,17 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
])
|
||||
if subscription_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_url
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([_fallback_connect_button()])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect"
|
||||
)
|
||||
])
|
||||
keyboard.append([_fallback_connect_button()])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
@@ -170,6 +181,15 @@ def get_main_menu_keyboard(
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, url=status_url)
|
||||
])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
|
||||
@@ -198,6 +218,11 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
if settings.DEBUG:
|
||||
print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена")
|
||||
# Moderator access (limited support panel)
|
||||
if (not is_admin) and is_moderator:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🧑⚖️ Модерация", callback_data="moderator_panel")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -254,29 +279,36 @@ def get_server_status_keyboard(
|
||||
def get_insufficient_balance_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
resume_callback: str | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
amount_kopeks: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard: list[list[InlineKeyboardButton]] = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.GO_TO_BALANCE_TOP_UP,
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
]
|
||||
]
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language)
|
||||
|
||||
if resume_callback:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if keyboard.inline_keyboard:
|
||||
last_row = keyboard.inline_keyboard[-1]
|
||||
if (
|
||||
len(last_row) == 1
|
||||
and isinstance(last_row[0], InlineKeyboardButton)
|
||||
and last_row[0].callback_data == "menu_balance"
|
||||
):
|
||||
keyboard.inline_keyboard[-1][0] = InlineKeyboardButton(
|
||||
text=last_row[0].text,
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
return keyboard
|
||||
|
||||
|
||||
def get_subscription_keyboard(
|
||||
@@ -351,8 +383,11 @@ def get_subscription_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = get_payment_methods_keyboard(0, language)
|
||||
def get_payment_methods_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
|
||||
|
||||
# Добавляем кнопку "Очистить корзину"
|
||||
keyboard.inline_keyboard.append([
|
||||
@@ -380,21 +415,26 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK
|
||||
)]
|
||||
])
|
||||
|
||||
def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💰 Пополнить баланс",
|
||||
callback_data="balance_topup"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🗑️ Очистить корзину",
|
||||
callback_data="clear_saved_cart"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
def get_insufficient_balance_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = get_insufficient_balance_keyboard(
|
||||
language,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Очистить корзину и вернуться",
|
||||
callback_data="clear_saved_cart",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return keyboard
|
||||
|
||||
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -608,67 +648,89 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
|
||||
amount_kopeks = max(0, int(amount_kopeks or 0))
|
||||
|
||||
def _build_callback(method: str) -> str:
|
||||
if amount_kopeks > 0:
|
||||
return f"topup_amount|{method}|{amount_kopeks}"
|
||||
return f"topup_{method}"
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"),
|
||||
callback_data="topup_stars"
|
||||
text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"),
|
||||
callback_data=_build_callback("stars")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
|
||||
callback_data="topup_yookassa"
|
||||
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
|
||||
callback_data=_build_callback("yookassa")
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
|
||||
callback_data="topup_yookassa_sbp"
|
||||
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
|
||||
callback_data=_build_callback("yookassa_sbp")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"),
|
||||
callback_data="topup_tribute"
|
||||
text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"),
|
||||
callback_data=_build_callback("tribute")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
|
||||
callback_data=_build_callback("pal24")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"),
|
||||
callback_data="topup_cryptobot"
|
||||
text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"),
|
||||
callback_data=_build_callback("cryptobot")
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
|
||||
if len(keyboard) == 1:
|
||||
|
||||
if len(keyboard) == 1:
|
||||
keyboard.insert(0, [
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||
callback_data="payment_methods_unavailable"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_yookassa_payment_keyboard(
|
||||
@@ -1542,7 +1604,8 @@ def get_my_tickets_keyboard(
|
||||
tickets: List[dict],
|
||||
current_page: int = 1,
|
||||
total_pages: int = 1,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
page_prefix: str = "my_tickets_page_"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
@@ -1570,7 +1633,7 @@ def get_my_tickets_keyboard(
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"my_tickets_page_{current_page - 1}"
|
||||
callback_data=f"{page_prefix}{current_page - 1}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1585,7 +1648,7 @@ def get_my_tickets_keyboard(
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"my_tickets_page_{current_page + 1}"
|
||||
callback_data=f"{page_prefix}{current_page + 1}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1648,7 +1711,9 @@ def get_admin_tickets_keyboard(
|
||||
current_page: int = 1,
|
||||
total_pages: int = 1,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
scope: str = "all"
|
||||
scope: str = "all",
|
||||
*,
|
||||
back_callback: str = "admin_submenu_support"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
@@ -1713,7 +1778,7 @@ def get_admin_tickets_keyboard(
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
@@ -1722,7 +1787,9 @@ def get_admin_tickets_keyboard(
|
||||
def get_admin_ticket_view_keyboard(
|
||||
ticket_id: int,
|
||||
is_closed: bool = False,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
*,
|
||||
is_user_blocked: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
@@ -1743,14 +1810,16 @@ def get_admin_ticket_view_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
# Block controls: first row Unblock + Block forever, second row Block by time
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"),
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}")
|
||||
])
|
||||
# Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать"
|
||||
if is_user_blocked:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"),
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блок по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets")
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
@@ -403,4 +404,5 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
@@ -121,6 +123,7 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
@@ -403,4 +406,5 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
|
||||
# работу панели управления даже при отсутствии подписки. Важно делать
|
||||
# это до обращения к состоянию, чтобы не выполнять лишние операции.
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
|
||||
telegram_id,
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
from app.database.models import User, Subscription, Transaction, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -215,7 +216,17 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Transaction)
|
||||
.where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
deposit_count = deposit_count_result.scalar_one() or 0
|
||||
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
@@ -341,6 +352,8 @@ class AdminNotificationService:
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '💳 PayPalych (карта)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
@@ -818,7 +831,13 @@ class AdminNotificationService:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
# Respect runtime toggle for admin ticket notifications
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
except Exception:
|
||||
runtime_enabled = True
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
+111
-67
@@ -21,7 +21,9 @@ from app.database.models import (
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,29 +63,34 @@ class BackupService:
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models_ordered = [
|
||||
ServiceRule,
|
||||
SystemSetting,
|
||||
ServiceRule,
|
||||
Squad,
|
||||
PromoCode,
|
||||
ServerSquad,
|
||||
|
||||
User,
|
||||
|
||||
WelcomeText,
|
||||
PromoGroup,
|
||||
User,
|
||||
PromoCode,
|
||||
WelcomeText,
|
||||
UserMessage,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionConversion,
|
||||
Transaction,
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionConversion,
|
||||
SentNotification,
|
||||
BroadcastHistory,
|
||||
UserMessage,
|
||||
|
||||
SentNotification,
|
||||
SubscriptionServer,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
]
|
||||
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
@@ -329,60 +336,47 @@ class BackupService:
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users":
|
||||
|
||||
pre_restore_tables = {"promo_groups"}
|
||||
for table_name in pre_restore_tables:
|
||||
model = models_by_table.get(table_name)
|
||||
if not model:
|
||||
continue
|
||||
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_records += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users" or table_name in pre_restore_tables:
|
||||
continue
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -549,14 +543,64 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
model,
|
||||
table_name: str,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
restored_count = 0
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses", "transactions",
|
||||
"yookassa_payments", "cryptobot_payments", "welcome_texts",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"squads", "service_rules", "system_settings", "monitoring_logs"
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import (
|
||||
get_expired_subscriptions, get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay, deactivate_subscription,
|
||||
extend_subscription
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
from app.database.crud.discount_offer import (
|
||||
deactivate_expired_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.notification import (
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
extend_subscription,
|
||||
get_expired_subscriptions,
|
||||
get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay,
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
delete_user,
|
||||
get_inactive_users,
|
||||
get_user_by_id,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
RemnaWaveAPIError,
|
||||
RemnaWaveUser,
|
||||
TrafficLimitStrategy,
|
||||
UserStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
@@ -40,8 +59,82 @@ class MonitoringService:
|
||||
self.subscription_service = SubscriptionService()
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set()
|
||||
self._notified_users: Set[str] = set()
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
self._sla_task = None
|
||||
|
||||
async def _send_message_with_logo(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
reply_markup=None,
|
||||
parse_mode: Optional[str] = "HTML",
|
||||
):
|
||||
"""Отправляет сообщение, добавляя логотип при необходимости."""
|
||||
if not self.bot:
|
||||
raise RuntimeError("Bot instance is not available")
|
||||
|
||||
if (
|
||||
settings.ENABLE_LOGO_MODE
|
||||
and LOGO_PATH.exists()
|
||||
and (text is None or len(text) <= 1000)
|
||||
):
|
||||
try:
|
||||
return await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning(
|
||||
"Не удалось отправить сообщение с логотипом пользователю %s: %s. "
|
||||
"Отправляем текстовое сообщение.",
|
||||
chat_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_unreachable_error(error: TelegramBadRequest) -> bool:
|
||||
message = str(error).lower()
|
||||
unreachable_markers = (
|
||||
"chat not found",
|
||||
"user is deactivated",
|
||||
"bot was blocked by the user",
|
||||
"bot can't initiate conversation",
|
||||
"can't initiate conversation",
|
||||
"user not found",
|
||||
"peer id invalid",
|
||||
)
|
||||
return any(marker in message for marker in unreachable_markers)
|
||||
|
||||
def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool:
|
||||
if isinstance(error, TelegramForbiddenError):
|
||||
logger.warning(
|
||||
"⚠️ Пользователь %s недоступен (%s): бот заблокирован",
|
||||
user.telegram_id,
|
||||
context,
|
||||
)
|
||||
return True
|
||||
|
||||
if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error):
|
||||
logger.warning(
|
||||
"⚠️ Пользователь %s недоступен (%s): %s",
|
||||
user.telegram_id,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
@@ -50,6 +143,12 @@ class MonitoringService:
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Запуск службы мониторинга")
|
||||
# Start dedicated SLA loop with its own interval for timely 5-min checks
|
||||
try:
|
||||
if not self._sla_task or self._sla_task.done():
|
||||
self._sla_task = asyncio.create_task(self._sla_loop())
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось запустить SLA-мониторинг: {e}")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -63,15 +162,26 @@ class MonitoringService:
|
||||
def stop_monitoring(self):
|
||||
self.is_running = False
|
||||
logger.info("ℹ️ Мониторинг остановлен")
|
||||
try:
|
||||
if self._sla_task and not self._sla_task.done():
|
||||
self._sla_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._cleanup_notification_cache()
|
||||
|
||||
|
||||
expired_offers = await deactivate_expired_offers(db)
|
||||
if expired_offers:
|
||||
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_inactivity_notifications(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
@@ -238,7 +348,7 @@ class MonitoringService:
|
||||
async def _check_trial_expiring_soon(self, db: AsyncSession):
|
||||
try:
|
||||
threshold_time = datetime.utcnow() + timedelta(hours=2)
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
@@ -276,7 +386,202 @@ class MonitoringService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
|
||||
|
||||
|
||||
async def _check_trial_inactivity_notifications(self, db: AsyncSession):
|
||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||
return
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
one_hour_ago = now - timedelta(hours=1)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.start_date.isnot(None),
|
||||
Subscription.start_date <= one_hour_ago,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
sent_1h = 0
|
||||
sent_24h = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if (subscription.traffic_used_gb or 0) > 0:
|
||||
continue
|
||||
|
||||
start_date = subscription.start_date
|
||||
if not start_date:
|
||||
continue
|
||||
|
||||
time_since_start = now - start_date
|
||||
|
||||
if (NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
and timedelta(hours=1) <= time_since_start < timedelta(hours=24)):
|
||||
if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"):
|
||||
success = await self._send_trial_inactive_notification(user, subscription, 1)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "trial_inactive_1h")
|
||||
sent_1h += 1
|
||||
|
||||
if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24):
|
||||
if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"):
|
||||
success = await self._send_trial_inactive_notification(user, subscription, 24)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "trial_inactive_24h")
|
||||
sent_24h += 1
|
||||
|
||||
if sent_1h or sent_24h:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
"trial_inactivity_notifications",
|
||||
f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа",
|
||||
{"sent_1h": sent_1h, "sent_24h": sent_24h},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}")
|
||||
|
||||
async def _check_expired_subscription_followups(self, db: AsyncSession):
|
||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||
return
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial == False,
|
||||
Subscription.end_date <= now,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
sent_day1 = 0
|
||||
sent_wave2 = 0
|
||||
sent_wave3 = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if subscription.end_date is None:
|
||||
continue
|
||||
|
||||
time_since_end = now - subscription.end_date
|
||||
if time_since_end.total_seconds() < 0:
|
||||
continue
|
||||
|
||||
days_since = time_since_end.total_seconds() / 86400
|
||||
|
||||
# Day 1 reminder
|
||||
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_1d"):
|
||||
success = await self._send_expired_day1_notification(user, subscription)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expired_1d")
|
||||
sent_day1 += 1
|
||||
|
||||
# Second wave (2-3 days) discount
|
||||
if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave2",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
valid_hours=valid_hours,
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
subscription,
|
||||
percent,
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"second",
|
||||
bonus_amount,
|
||||
)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
|
||||
sent_wave2 += 1
|
||||
|
||||
# Third wave (N days) discount
|
||||
if NotificationSettingsService.is_third_wave_enabled():
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
if trigger_days <= days_since < trigger_days + 1:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave3",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
valid_hours=valid_hours,
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
subscription,
|
||||
percent,
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"third",
|
||||
bonus_amount,
|
||||
trigger_days=trigger_days,
|
||||
)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expired_discount_wave3")
|
||||
sent_wave3 += 1
|
||||
|
||||
if sent_day1 or sent_wave2 or sent_wave3:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
"expired_followups_sent",
|
||||
(
|
||||
"Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format(
|
||||
sent_day1,
|
||||
sent_wave2,
|
||||
sent_wave3,
|
||||
)
|
||||
),
|
||||
{
|
||||
"day1": sent_day1,
|
||||
"wave2": sent_wave2,
|
||||
"wave3": sent_wave3,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}")
|
||||
|
||||
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
threshold_date = current_time + timedelta(days=days_before)
|
||||
@@ -393,17 +698,30 @@ class MonitoringService:
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление об истечении подписки"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об истечении подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
|
||||
@@ -437,23 +755,36 @@ class MonitoringService:
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление об истекающей подписке"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об истечении подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
@@ -477,19 +808,213 @@ class MonitoringService:
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление о завершении тестовой подписки"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
if hours >= 24:
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_24H",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
else:
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
|
||||
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
|
||||
),
|
||||
)
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
|
||||
[InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление о бездействии на тесте"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\n\n"
|
||||
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "напоминание об истекшей подписке"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка отправки напоминания об истекшей подписке пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_discount_notification(
|
||||
self,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
percent: int,
|
||||
expires_at: datetime,
|
||||
offer_id: int,
|
||||
wave: str,
|
||||
bonus_amount: int,
|
||||
trigger_days: int = None,
|
||||
) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if wave == "second":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
else:
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
|
||||
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "скидочное уведомление"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка отправки скидочного уведомления пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
@@ -497,10 +1022,25 @@ class MonitoringService:
|
||||
days=days,
|
||||
amount=settings.format_price(amount)
|
||||
)
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"):
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об автоплатеже пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
@@ -516,15 +1056,26 @@ class MonitoringService:
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if not self._handle_unreachable_user(user, exc, "уведомление о неудачном автоплатеже"):
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
@@ -576,6 +1127,107 @@ class MonitoringService:
|
||||
is_success=False
|
||||
)
|
||||
|
||||
async def _check_ticket_sla(self, db: AsyncSession):
|
||||
try:
|
||||
# Quick guards
|
||||
# Allow runtime toggle from SupportSettingsService
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
sla_enabled_runtime = SupportSettingsService.get_sla_enabled()
|
||||
except Exception:
|
||||
sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True)
|
||||
if not sla_enabled_runtime:
|
||||
return
|
||||
if not self.bot:
|
||||
return
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
return
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes()))
|
||||
except Exception:
|
||||
sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5)))
|
||||
cooldown_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', 15)))
|
||||
now = datetime.utcnow()
|
||||
stale_before = now - timedelta(minutes=sla_minutes)
|
||||
cooldown_before = now - timedelta(minutes=cooldown_minutes)
|
||||
|
||||
# Tickets to remind: open, no admin reply yet after user's last message (status OPEN), stale by SLA,
|
||||
# and either never reminded or cooldown passed
|
||||
result = await db.execute(
|
||||
select(Ticket)
|
||||
.options(selectinload(Ticket.user))
|
||||
.where(
|
||||
and_(
|
||||
Ticket.status == TicketStatus.OPEN.value,
|
||||
Ticket.updated_at <= stale_before,
|
||||
or_(Ticket.last_sla_reminder_at.is_(None), Ticket.last_sla_reminder_at <= cooldown_before),
|
||||
)
|
||||
)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
reminders_sent = 0
|
||||
service = AdminNotificationService(self.bot)
|
||||
|
||||
for ticket in tickets:
|
||||
try:
|
||||
waited_minutes = max(0, int((now - ticket.updated_at).total_seconds() // 60))
|
||||
title = (ticket.title or '').strip()
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
text = (
|
||||
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
|
||||
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
|
||||
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
|
||||
)
|
||||
|
||||
sent = await service.send_ticket_event_notification(text)
|
||||
if sent:
|
||||
ticket.last_sla_reminder_at = now
|
||||
reminders_sent += 1
|
||||
# commit after each to persist timestamp and avoid duplicate reminders on crash
|
||||
await db.commit()
|
||||
except Exception as notify_error:
|
||||
logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}")
|
||||
|
||||
if reminders_sent > 0:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
"ticket_sla_reminders_sent",
|
||||
f"Отправлено {reminders_sent} SLA-напоминаний по тикетам",
|
||||
{"count": reminders_sent},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки SLA тикетов: {e}")
|
||||
|
||||
async def _sla_loop(self):
|
||||
try:
|
||||
interval_seconds = max(10, int(getattr(settings, 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS', 60)))
|
||||
except Exception:
|
||||
interval_seconds = 60
|
||||
while self.is_running:
|
||||
try:
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._check_ticket_sla(db)
|
||||
finally:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в SLA-цикле: {e}")
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
async def _log_monitoring_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MulenPayService:
|
||||
"""Интеграция с Mulen Pay API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.api_key = settings.MULENPAY_API_KEY
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
settings.is_mulenpay_enabled()
|
||||
and self.api_key
|
||||
and self.shop_id
|
||||
and self.secret_key
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
return f"{amount_kopeks / 100:.2f}"
|
||||
|
||||
def _build_signature(self, currency: str, amount_str: str) -> str:
|
||||
raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode()
|
||||
return hashlib.sha1(raw_string).hexdigest()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
uuid: str,
|
||||
items: list,
|
||||
language: str = "ru",
|
||||
subscribe: Optional[str] = None,
|
||||
hold_time: Optional[int] = None,
|
||||
website_url: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
amount_str = self._format_amount(amount_kopeks)
|
||||
currency = "rub"
|
||||
payload = {
|
||||
"currency": currency,
|
||||
"amount": amount_str,
|
||||
"uuid": uuid,
|
||||
"shopId": self.shop_id,
|
||||
"description": description,
|
||||
"items": items,
|
||||
"language": language,
|
||||
"sign": self._build_signature(currency, amount_str),
|
||||
}
|
||||
|
||||
if subscribe:
|
||||
payload["subscribe"] = subscribe
|
||||
if hold_time is not None:
|
||||
payload["holdTime"] = hold_time
|
||||
if website_url:
|
||||
payload["website_url"] = website_url
|
||||
|
||||
response = await self._request("POST", "/v2/payments", json_data=payload)
|
||||
if not response or not response.get("success"):
|
||||
logger.error("Failed to create MulenPay payment: %s", response)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationSettingsService:
|
||||
"""Runtime-editable notification settings stored on disk."""
|
||||
|
||||
_storage_path: Path = Path("data/notification_settings.json")
|
||||
_data: Dict[str, Dict[str, Any]] = {}
|
||||
_loaded: bool = False
|
||||
|
||||
_DEFAULTS: Dict[str, Dict[str, Any]] = {
|
||||
"trial_inactive_1h": {"enabled": True},
|
||||
"trial_inactive_24h": {"enabled": True},
|
||||
"expired_1d": {"enabled": True},
|
||||
"expired_second_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 10,
|
||||
"valid_hours": 24,
|
||||
},
|
||||
"expired_third_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 20,
|
||||
"valid_hours": 24,
|
||||
"trigger_days": 5,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc: # pragma: no cover - filesystem guard
|
||||
logger.error("Failed to create notification settings dir: %s", exc)
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
raw = cls._storage_path.read_text(encoding="utf-8")
|
||||
cls._data = json.loads(raw) if raw.strip() else {}
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load notification settings: %s", exc)
|
||||
cls._data = {}
|
||||
|
||||
changed = cls._apply_defaults()
|
||||
if changed:
|
||||
cls._save()
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _apply_defaults(cls) -> bool:
|
||||
changed = False
|
||||
for key, defaults in cls._DEFAULTS.items():
|
||||
current = cls._data.get(key)
|
||||
if not isinstance(current, dict):
|
||||
cls._data[key] = deepcopy(defaults)
|
||||
changed = True
|
||||
continue
|
||||
|
||||
for def_key, def_value in defaults.items():
|
||||
if def_key not in current:
|
||||
current[def_key] = def_value
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(
|
||||
json.dumps(cls._data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save notification settings: %s", exc)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get(cls, key: str) -> Dict[str, Any]:
|
||||
cls._load()
|
||||
value = cls._data.get(key)
|
||||
if not isinstance(value, dict):
|
||||
value = deepcopy(cls._DEFAULTS.get(key, {}))
|
||||
cls._data[key] = value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Dict[str, Any]]:
|
||||
cls._load()
|
||||
return deepcopy(cls._data)
|
||||
|
||||
@classmethod
|
||||
def _set_field(cls, key: str, field: str, value: Any) -> bool:
|
||||
cls._load()
|
||||
section = cls._get(key)
|
||||
section[field] = value
|
||||
cls._data[key] = section
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def set_enabled(cls, key: str, enabled: bool) -> bool:
|
||||
return cls._set_field(key, "enabled", bool(enabled))
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls, key: str) -> bool:
|
||||
return bool(cls._get(key).get("enabled", True))
|
||||
|
||||
# Trial inactivity helpers
|
||||
@classmethod
|
||||
def is_trial_inactive_1h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_1h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_1h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_inactive_24h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_24h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_24h", enabled)
|
||||
|
||||
# Expired subscription notifications
|
||||
@classmethod
|
||||
def is_expired_1d_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_1d")
|
||||
|
||||
@classmethod
|
||||
def set_expired_1d_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_1d", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_second_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_second_wave")
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_second_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("discount_percent", 10)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def is_third_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_third_wave")
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_third_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("discount_percent", 20)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 20
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_trigger_days(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("trigger_days", 5)
|
||||
try:
|
||||
return max(2, min(60, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_trigger_days(cls, days: int) -> bool:
|
||||
try:
|
||||
days_int = max(2, min(60, int(days)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "trigger_days", days_int)
|
||||
|
||||
@classmethod
|
||||
def are_notifications_globally_enabled(cls) -> bool:
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
@@ -0,0 +1,116 @@
|
||||
"""High level integration with PayPalych API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.external.pal24_client import Pal24Client, Pal24APIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24Service:
|
||||
"""Wrapper around :class:`Pal24Client` providing domain helpers."""
|
||||
|
||||
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
|
||||
BILL_FAILED_STATES = {"FAIL", "CANCELLED"}
|
||||
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
|
||||
def __init__(self, client: Optional[Pal24Client] = None) -> None:
|
||||
self.client = client or Pal24Client()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.client.is_configured and settings.is_pal24_enabled()
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
description: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
custom_payload: Optional[Dict[str, Any]] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 service is not configured")
|
||||
|
||||
amount_decimal = Pal24Client.normalize_amount(amount_kopeks)
|
||||
extra_payload: Dict[str, Any] = {
|
||||
"custom": custom_payload or {},
|
||||
"ttl": ttl_seconds,
|
||||
}
|
||||
|
||||
if payer_email:
|
||||
extra_payload["payer_email"] = payer_email
|
||||
|
||||
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
|
||||
|
||||
logger.info(
|
||||
"Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s",
|
||||
user_id,
|
||||
order_id,
|
||||
amount_decimal,
|
||||
ttl_seconds,
|
||||
)
|
||||
|
||||
response = await self.client.create_bill(
|
||||
amount=amount_decimal,
|
||||
shop_id=settings.PAL24_SHOP_ID,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
type_="normal",
|
||||
**filtered_payload,
|
||||
)
|
||||
|
||||
logger.info("Pal24 счет создан: %s", response)
|
||||
return response
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 счета %s", bill_id)
|
||||
return await self.client.get_bill_status(bill_id)
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def convert_to_kopeks(amount: str) -> int:
|
||||
decimal_amount = Decimal(str(amount))
|
||||
return int((decimal_amount * Decimal("100")).quantize(Decimal("1")))
|
||||
|
||||
@staticmethod
|
||||
def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]:
|
||||
if not ttl_seconds:
|
||||
return None
|
||||
return datetime.utcnow() + timedelta(seconds=ttl_seconds)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
@@ -26,6 +28,24 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.database.crud.mulenpay import (
|
||||
create_mulenpay_payment,
|
||||
get_mulenpay_payment_by_local_id,
|
||||
get_mulenpay_payment_by_uuid,
|
||||
get_mulenpay_payment_by_mulen_id,
|
||||
update_mulenpay_payment_status,
|
||||
link_mulenpay_payment_to_transaction,
|
||||
)
|
||||
from app.database.crud.pal24 import (
|
||||
create_pal24_payment,
|
||||
get_pal24_payment_by_bill_id,
|
||||
get_pal24_payment_by_id,
|
||||
get_pal24_payment_by_order_id,
|
||||
link_pal24_payment_to_transaction,
|
||||
update_pal24_payment_status,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +57,8 @@ class PaymentService:
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None
|
||||
self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None
|
||||
|
||||
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
@@ -633,7 +655,7 @@ class PaymentService:
|
||||
description: str = "Пополнение баланса",
|
||||
payload: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.error("CryptoBot сервис не инициализирован")
|
||||
return None
|
||||
@@ -686,7 +708,657 @@ class PaymentService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.mulenpay_service:
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
items = [
|
||||
{
|
||||
"description": description[:128],
|
||||
"quantity": 1,
|
||||
"price": round(amount_rubles, 2),
|
||||
"vat_code": settings.MULENPAY_VAT_CODE,
|
||||
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
|
||||
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
|
||||
}
|
||||
]
|
||||
|
||||
response = await self.mulenpay_service.create_payment(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
uuid=payment_uuid,
|
||||
items=items,
|
||||
language=language or settings.MULENPAY_LANGUAGE,
|
||||
website_url=settings.WEBHOOK_URL,
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
payment_url = response.get("paymentUrl")
|
||||
|
||||
metadata = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
local_payment = await create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=payment_uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency="RUB",
|
||||
status="created",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"mulen_payment_id": mulen_payment_id,
|
||||
"payment_url": payment_url,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"uuid": payment_uuid,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
return None
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
|
||||
|
||||
custom_payload = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.pal24_service.create_bill(
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
return None
|
||||
|
||||
if not response.get("success", True):
|
||||
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
|
||||
return None
|
||||
|
||||
bill_id = response.get("bill_id")
|
||||
if not bill_id:
|
||||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||||
return None
|
||||
|
||||
link_url = response.get("link_url")
|
||||
link_page_url = response.get("link_page_url")
|
||||
|
||||
payment = await create_pal24_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
status=response.get("status", "NEW"),
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
|
||||
payment_info = {
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": link_url or link_page_url,
|
||||
"link_page_url": link_page_url,
|
||||
"local_payment_id": payment.id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 счет %s для пользователя %s на сумму %s",
|
||||
bill_id,
|
||||
user_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
|
||||
return payment_info
|
||||
|
||||
async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool:
|
||||
try:
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
try:
|
||||
mulen_payment_id_int = int(mulen_payment_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if uuid_value:
|
||||
payment = await get_mulenpay_payment_by_uuid(db, uuid_value)
|
||||
|
||||
if not payment and mulen_payment_id_int is not None:
|
||||
payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("MulenPay платеж %s уже обработан", payment.uuid)
|
||||
return True
|
||||
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
if payment_status == "success":
|
||||
try:
|
||||
amount_kopeks = int(Decimal(str(amount_value)) * 100)
|
||||
except (InvalidOperation, TypeError):
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
logger.warning(
|
||||
"Не удалось распарсить сумму MulenPay, используем значение из БД: %s",
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=(
|
||||
str(mulen_payment_id_int)
|
||||
if mulen_payment_id_int is not None
|
||||
else payment.uuid
|
||||
),
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_mulenpay_payment_to_transaction(
|
||||
db,
|
||||
payment=payment,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения MulenPay: %s",
|
||||
referral_error,
|
||||
)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="success",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления MulenPay: %s",
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
user_notify_error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_status == "cancel":
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="canceled",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
return True
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=payment_status or "unknown",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
order_id_raw = payload.get("InvId")
|
||||
order_id = str(order_id_raw) if order_id_raw is not None else None
|
||||
if not order_id:
|
||||
logger.error("Pal24 postback без InvId")
|
||||
return False
|
||||
|
||||
payment = await get_pal24_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
bill_id = payload.get("BillId")
|
||||
if bill_id:
|
||||
payment = await get_pal24_payment_by_bill_id(db, str(bill_id))
|
||||
|
||||
if not payment:
|
||||
logger.error("Pal24 платеж не найден для order_id=%s", order_id)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
status = str(payload.get("Status", "UNKNOWN")).upper()
|
||||
payment_id = payload.get("TrsId")
|
||||
balance_amount = payload.get("BalanceAmount")
|
||||
balance_currency = payload.get("BalanceCurrency")
|
||||
payer_account = payload.get("AccountNumber")
|
||||
payment_method = payload.get("AccountType")
|
||||
|
||||
try:
|
||||
amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum")))
|
||||
except Exception:
|
||||
logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение")
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы Pal24: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
is_success = status in Pal24Service.BILL_SUCCESS_STATES
|
||||
is_failed = status in Pal24Service.BILL_FAILED_STATES
|
||||
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_active=not is_failed,
|
||||
is_paid=is_success,
|
||||
payment_id=str(payment_id) if payment_id else None,
|
||||
payment_status=status,
|
||||
payment_method=str(payment_method) if payment_method else None,
|
||||
balance_amount=str(balance_amount) if balance_amount is not None else None,
|
||||
balance_currency=str(balance_currency) if balance_currency is not None else None,
|
||||
payer_account=str(payer_account) if payer_account is not None else None,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
if not is_success:
|
||||
logger.info(
|
||||
"Получен Pal24 статус %s для платежа %s (успех=%s)",
|
||||
status,
|
||||
payment.bill_id,
|
||||
is_success,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _map_mulenpay_status(status_code: Optional[int]) -> str:
|
||||
mapping = {
|
||||
0: "created",
|
||||
1: "processing",
|
||||
2: "canceled",
|
||||
3: "success",
|
||||
4: "error",
|
||||
5: "hold",
|
||||
6: "hold",
|
||||
}
|
||||
return mapping.get(status_code, "unknown")
|
||||
|
||||
async def get_mulenpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_mulenpay_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status_code = None
|
||||
remote_data = None
|
||||
|
||||
if (
|
||||
self.mulenpay_service
|
||||
and payment.mulen_payment_id is not None
|
||||
):
|
||||
response = await self.mulenpay_service.get_payment(payment.mulen_payment_id)
|
||||
if response and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status_code": remote_status_code,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
|
||||
if self.pal24_service and payment.bill_id:
|
||||
try:
|
||||
response = await self.pal24_service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = (
|
||||
response.get("status")
|
||||
or response.get("bill", {}).get("status")
|
||||
)
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса: %s", error)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.cryptobot import (
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
if total_spent_kopeks <= 0:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc())
|
||||
)
|
||||
groups = result.scalars().all()
|
||||
|
||||
for group in groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
if threshold and total_spent_kopeks >= threshold:
|
||||
return group
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def maybe_assign_promo_group_by_total_spent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
|
||||
target_group = await _get_best_group_for_spending(db, total_spent)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
logger.debug(
|
||||
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
logger.info(
|
||||
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
total_spent / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return target_group
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка при автоматическом назначении промогруппы пользователю %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
await db.rollback()
|
||||
return None
|
||||
@@ -0,0 +1,351 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as datetime_time, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportingServiceError(RuntimeError):
|
||||
"""Base error for the reporting service."""
|
||||
|
||||
|
||||
class ReportPeriod(Enum):
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReportPeriodRange:
|
||||
start_msk: datetime
|
||||
end_msk: datetime
|
||||
label: str
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Generates admin summary reports and can schedule daily delivery."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._moscow_tz = ZoneInfo("Europe/Moscow")
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.ADMIN_REPORTS_ENABLED:
|
||||
logger.info("Сервис отчетов отключен настройками")
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.warning("Невозможно запустить сервис отчетов без экземпляра бота")
|
||||
return
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов")
|
||||
return
|
||||
|
||||
send_time = settings.get_reports_send_time()
|
||||
if not send_time:
|
||||
logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._auto_daily_loop(send_time))
|
||||
logger.info(
|
||||
"📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК",
|
||||
send_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def send_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
*,
|
||||
report_date: Optional[date] = None,
|
||||
send_to_topic: bool = False,
|
||||
) -> str:
|
||||
report_text = await self._build_report(period, report_date)
|
||||
|
||||
if send_to_topic:
|
||||
await self._deliver_report(report_text)
|
||||
|
||||
return report_text
|
||||
|
||||
async def _auto_daily_loop(self, send_time: datetime_time) -> None:
|
||||
try:
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
while True:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
delay = (next_run_utc - now_utc).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
await self.send_report(
|
||||
ReportPeriod.DAILY,
|
||||
report_date=report_date,
|
||||
send_to_topic=True,
|
||||
)
|
||||
logger.info(
|
||||
"📊 Автоматический отчет за %s отправлен",
|
||||
report_date.strftime("%d.%m.%Y"),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка автоматической отправки отчета: %s", exc)
|
||||
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис отчетов остановлен")
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Критическая ошибка в сервисе отчетов: %s", exc)
|
||||
|
||||
def _calculate_next_run(
|
||||
self,
|
||||
send_time: datetime_time,
|
||||
) -> Tuple[datetime, date]:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz)
|
||||
|
||||
if now_msk >= candidate:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
report_date = (candidate - timedelta(days=1)).date()
|
||||
return candidate.astimezone(timezone.utc), report_date
|
||||
|
||||
async def _deliver_report(self, report_text: str) -> None:
|
||||
if not self.bot:
|
||||
raise ReportingServiceError("Бот не инициализирован для отправки отчета")
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
raise ReportingServiceError("Не задан чат для отправки отчета")
|
||||
|
||||
topic_id = settings.get_reports_topic_id()
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=report_text,
|
||||
message_thread_id=topic_id,
|
||||
)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.error("Не удалось отправить отчет: %s", exc)
|
||||
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
|
||||
|
||||
async def _build_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> str:
|
||||
period_range = self._get_period_range(period, report_date)
|
||||
start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
totals = await self._collect_current_totals(session)
|
||||
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
|
||||
|
||||
header = (
|
||||
f"📊 <b>Отчет за {period_range.label}</b>"
|
||||
if period == ReportPeriod.DAILY
|
||||
else f"📊 <b>Отчет за период {period_range.label}</b>"
|
||||
)
|
||||
|
||||
lines = [
|
||||
header,
|
||||
"",
|
||||
"🎯 <b>Триалы</b>",
|
||||
f"• Активных сейчас: {totals['active_trials']}",
|
||||
f"• Новых за период: {period_stats['new_trials']}",
|
||||
"",
|
||||
"💎 <b>Платные подписки</b>",
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
f"• Пополнений: {period_stats['deposits_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['deposits_amount'])}",
|
||||
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['total_payments_amount'])}",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_period_range(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> ReportPeriodRange:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
|
||||
if period == ReportPeriod.DAILY:
|
||||
target_date = report_date or (now_msk.date() - timedelta(days=1))
|
||||
start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = start + timedelta(days=1)
|
||||
elif period == ReportPeriod.WEEKLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
elif period == ReportPeriod.MONTHLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
else: # pragma: no cover - defensive branch
|
||||
raise ReportingServiceError(f"Неизвестный период отчета: {period}")
|
||||
|
||||
label = self._format_period_label(start, end)
|
||||
return ReportPeriodRange(start, end, label)
|
||||
|
||||
async def _collect_current_totals(self, session) -> dict:
|
||||
stats = await get_subscriptions_statistics(session)
|
||||
return {
|
||||
"active_trials": stats.get("trial_subscriptions", 0) or 0,
|
||||
"active_paid": stats.get("paid_subscriptions", 0) or 0,
|
||||
}
|
||||
|
||||
async def _collect_period_stats(
|
||||
self,
|
||||
session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
) -> dict:
|
||||
new_trials_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
new_trials = int(new_trials_result.scalar() or 0)
|
||||
|
||||
direct_paid_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
direct_paid = int(direct_paid_result.scalar() or 0)
|
||||
|
||||
conversions_result = await session.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
SubscriptionConversion.converted_at >= start_utc,
|
||||
SubscriptionConversion.converted_at < end_utc,
|
||||
)
|
||||
)
|
||||
conversions_count = int(conversions_result.scalar() or 0)
|
||||
|
||||
subscription_payments_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
deposits_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
subscription_payments_count = int(subscription_payments_row[0] or 0)
|
||||
subscription_payments_amount = int(subscription_payments_row[1] or 0)
|
||||
deposits_count = int(deposits_row[0] or 0)
|
||||
deposits_amount = int(deposits_row[1] or 0)
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
|
||||
return {
|
||||
"new_trials": new_trials,
|
||||
"new_paid_subscriptions": direct_paid + conversions_count,
|
||||
"subscription_payments_count": subscription_payments_count,
|
||||
"subscription_payments_amount": subscription_payments_amount,
|
||||
"deposits_count": deposits_count,
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
}
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
start_date = start.astimezone(self._moscow_tz).date()
|
||||
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
|
||||
end_date = end_boundary.date()
|
||||
|
||||
if start_date == end_date:
|
||||
return start_date.strftime("%d.%m.%Y")
|
||||
|
||||
return (
|
||||
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
)
|
||||
|
||||
def _format_amount(self, amount_kopeks: int) -> str:
|
||||
if not amount_kopeks:
|
||||
return "0 ₽"
|
||||
|
||||
rubles = amount_kopeks / 100
|
||||
return f"{rubles:,.2f} ₽".replace(",", " ")
|
||||
|
||||
|
||||
reporting_service = ReportingService()
|
||||
|
||||
@@ -49,4 +49,10 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
return bool(getattr(subscription, "is_trial", False))
|
||||
if getattr(subscription, "is_trial", False):
|
||||
return True
|
||||
|
||||
if getattr(subscription, "actual_status", None) == "expired":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -299,7 +299,15 @@ class SubscriptionService:
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -353,7 +361,13 @@ class SubscriptionService:
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
@@ -391,14 +405,16 @@ class SubscriptionService:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
@@ -430,6 +446,15 @@ class SubscriptionService:
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
@@ -438,7 +463,13 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if servers_price > 0:
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
@@ -518,9 +549,11 @@ class SubscriptionService:
|
||||
return False
|
||||
|
||||
async def get_countries_price_by_uuids(
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession,
|
||||
*,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
@@ -530,7 +563,12 @@ class SubscriptionService:
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
if server and server.is_available and not server.is_full:
|
||||
is_allowed = True
|
||||
if promo_group_id is not None and server:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = promo_group_id in allowed_ids
|
||||
|
||||
if server and server.is_available and not server.is_full and is_allowed:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
@@ -577,7 +615,15 @@ class SubscriptionService:
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -637,7 +683,13 @@ class SubscriptionService:
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -681,14 +733,16 @@ class SubscriptionService:
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
@@ -723,10 +777,25 @@ class SubscriptionService:
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
|
||||
@@ -110,3 +110,112 @@ class SupportSettingsService:
|
||||
return cls._save()
|
||||
|
||||
|
||||
# Notifications & SLA
|
||||
@classmethod
|
||||
def get_admin_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "admin_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["admin_ticket_notifications_enabled"])
|
||||
# fallback to global admin notifications setting
|
||||
return bool(settings.is_admin_notifications_enabled())
|
||||
|
||||
@classmethod
|
||||
def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["admin_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_user_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "user_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["user_ticket_notifications_enabled"])
|
||||
# fallback to global enable notifications
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
|
||||
@classmethod
|
||||
def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["user_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "ticket_sla_enabled" in cls._data:
|
||||
return bool(cls._data["ticket_sla_enabled"])
|
||||
return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True))
|
||||
|
||||
@classmethod
|
||||
def set_sla_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["ticket_sla_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_minutes(cls) -> int:
|
||||
cls._load()
|
||||
minutes = cls._data.get("ticket_sla_minutes")
|
||||
if isinstance(minutes, int) and minutes > 0:
|
||||
return minutes
|
||||
return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5))
|
||||
|
||||
@classmethod
|
||||
def set_sla_minutes(cls, minutes: int) -> bool:
|
||||
try:
|
||||
minutes_int = int(minutes)
|
||||
except Exception:
|
||||
return False
|
||||
if minutes_int <= 0:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["ticket_sla_minutes"] = minutes_int
|
||||
return cls._save()
|
||||
|
||||
# Moderators management
|
||||
@classmethod
|
||||
def get_moderators(cls) -> list[int]:
|
||||
cls._load()
|
||||
raw = cls._data.get("moderators") or []
|
||||
moderators: list[int] = []
|
||||
for item in raw:
|
||||
try:
|
||||
moderators.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
return moderators
|
||||
|
||||
@classmethod
|
||||
def is_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
return tid in cls.get_moderators()
|
||||
|
||||
@classmethod
|
||||
def add_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
moderators.add(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def remove_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
if tid in moderators:
|
||||
moderators.remove(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
return True
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
AdvertisingCampaign
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -493,7 +494,7 @@ class UserService:
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
cryptobot_payments = cryptobot_result.scalars().all()
|
||||
|
||||
|
||||
if cryptobot_payments:
|
||||
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
|
||||
await db.execute(
|
||||
@@ -508,7 +509,49 @@ class UserService:
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
mulenpay_result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||
)
|
||||
mulenpay_payments = mulenpay_result.scalars().all()
|
||||
|
||||
if mulenpay_payments:
|
||||
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
|
||||
await db.execute(
|
||||
update(MulenPayPayment)
|
||||
.where(MulenPayPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
|
||||
|
||||
try:
|
||||
pal24_result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.user_id == user_id)
|
||||
)
|
||||
pal24_payments = pal24_result.scalars().all()
|
||||
|
||||
if pal24_payments:
|
||||
logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей")
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(Pal24Payment).where(Pal24Payment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
@@ -589,7 +632,7 @@ class UserService:
|
||||
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
broadcast_history = broadcast_history_result.scalars().all()
|
||||
|
||||
|
||||
if broadcast_history:
|
||||
logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок")
|
||||
await db.execute(
|
||||
@@ -598,6 +641,23 @@ class UserService:
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
|
||||
if campaigns:
|
||||
logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний")
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
|
||||
+9
-2
@@ -68,20 +68,26 @@ class AdminStates(StatesGroup):
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
creating_promo_group_period_discount = State()
|
||||
creating_promo_group_auto_assign = State()
|
||||
|
||||
editing_promo_group_menu = State()
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
editing_promo_group_period_discount = State()
|
||||
editing_promo_group_auto_assign = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
editing_notification_value = State()
|
||||
|
||||
confirming_sync = State()
|
||||
|
||||
editing_server_name = State()
|
||||
@@ -89,6 +95,7 @@ class AdminStates(StatesGroup):
|
||||
editing_server_country = State()
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
editing_server_promo_groups = State()
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
|
||||
+16
-1
@@ -67,13 +67,28 @@ class CacheService:
|
||||
async def delete(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
deleted = await self.redis_client.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из кеша {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
if not self._connected:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
deleted = await self.redis_client.delete(*keys)
|
||||
return int(deleted)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}")
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
|
||||
@@ -30,17 +30,35 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append({
|
||||
"id": "tribute",
|
||||
"name": "Банковская карта",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Tribute",
|
||||
"callback": "topup_tribute"
|
||||
})
|
||||
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
methods.append({
|
||||
"id": "mulenpay",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Mulen Pay",
|
||||
"callback": "topup_mulenpay"
|
||||
})
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append({
|
||||
"id": "pal24",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через PayPalych",
|
||||
"callback": "topup_pal24"
|
||||
})
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append({
|
||||
"id": "cryptobot",
|
||||
"name": "Криптовалюта",
|
||||
"icon": "🪙",
|
||||
"icon": "🪙",
|
||||
"description": "через CryptoBot",
|
||||
"callback": "topup_cryptobot"
|
||||
})
|
||||
@@ -112,6 +130,10 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_yookassa_enabled()
|
||||
elif method_id == "tribute":
|
||||
return settings.TRIBUTE_ENABLED
|
||||
elif method_id == "mulenpay":
|
||||
return settings.is_mulenpay_enabled()
|
||||
elif method_id == "pal24":
|
||||
return settings.is_pal24_enabled()
|
||||
elif method_id == "cryptobot":
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "support":
|
||||
@@ -127,6 +149,8 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"stars": settings.TELEGRAM_STARS_ENABLED,
|
||||
"yookassa": settings.is_yookassa_enabled(),
|
||||
"tribute": settings.TRIBUTE_ENABLED,
|
||||
"mulenpay": settings.is_mulenpay_enabled(),
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"support": True
|
||||
}
|
||||
@@ -139,9 +163,13 @@ def get_enabled_payment_methods_count() -> int:
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
count += 1
|
||||
if settings.is_yookassa_enabled():
|
||||
count += 1
|
||||
count += 1
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
count += 1
|
||||
if settings.is_mulenpay_enabled():
|
||||
count += 1
|
||||
if settings.is_pal24_enabled():
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
count += 1
|
||||
return count
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
|
||||
+62
-4
@@ -2,6 +2,7 @@
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"ADMIN_REPORTS": "📊 Reports",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"BACK": "⬅️ Back",
|
||||
@@ -37,6 +38,7 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
@@ -56,6 +58,8 @@
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
@@ -63,6 +67,14 @@
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_TOPUP_PROMPT": "💳 <b>PayPalych payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAY_BUTTON": "💳 Pay with PayPalych",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>PayPalych payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
@@ -95,6 +107,7 @@
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Active",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
|
||||
"SUCCESS": "✅ Success",
|
||||
"REGISTRATION_COMPLETING": "✅ Completing registration...",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
|
||||
@@ -117,6 +130,7 @@
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
@@ -127,12 +141,14 @@
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
"SUPPORT_BUTTON": "🆘 Support",
|
||||
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Low",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
|
||||
@@ -178,6 +194,9 @@
|
||||
"MARK_AS_ANSWERED": "✅ Mark as answered",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
|
||||
"CLOSE_NOTIFICATION": "❌ Close notification",
|
||||
"REPORT_CLOSE": "❌ Close",
|
||||
"REPORT_CLOSED": "✅ Report closed.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
"UNBLOCK": "✅ Unblock",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
@@ -214,14 +233,30 @@
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
@@ -239,6 +274,7 @@
|
||||
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
@@ -444,9 +480,31 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "💳 <b>Bank card (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):"
|
||||
}
|
||||
|
||||
+64
-6
@@ -5,6 +5,8 @@
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
|
||||
"ADMIN_REPORTS": "📊 Отчеты",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
"ADMIN_REFERRALS": "🤝 Партнерка",
|
||||
@@ -15,12 +17,14 @@
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
"SUPPORT_BUTTON": "🆘 Поддержка",
|
||||
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Низкий",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
|
||||
@@ -30,7 +34,7 @@
|
||||
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
|
||||
"VIEW_TICKET": "👁️ Посмотреть тикет",
|
||||
@@ -66,9 +70,12 @@
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
|
||||
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
|
||||
"REPORT_CLOSE": "❌ Закрыть",
|
||||
"REPORT_CLOSED": "✅ Отчет закрыт.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"BLOCK_FOREVER": "🚫 Блок навсегда",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
|
||||
"TICKET_ATTACHMENTS": "📎 Вложения",
|
||||
@@ -102,14 +109,30 @@
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
@@ -132,6 +155,7 @@
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
@@ -183,6 +207,7 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
@@ -214,6 +239,8 @@
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
@@ -221,6 +248,14 @@
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_TOPUP_PROMPT": "💳 <b>Оплата через PayPalych</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через PayPalych</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
@@ -265,6 +300,7 @@
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Активна",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
|
||||
@@ -444,9 +480,31 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "💳 <b>Банковская карта (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):"
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ from app.services.payment_service import PaymentService
|
||||
from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
|
||||
|
||||
@@ -55,6 +57,7 @@ async def main():
|
||||
|
||||
webhook_server = None
|
||||
yookassa_server_task = None
|
||||
pal24_server: Pal24WebhookServer | None = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
version_check_task = None
|
||||
@@ -109,15 +112,28 @@ async def main():
|
||||
logger.info("✅ Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
|
||||
|
||||
logger.info("📊 Инициализация сервиса отчетов...")
|
||||
try:
|
||||
reporting_service.set_bot(bot)
|
||||
await reporting_service.start()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
webhook_needed = settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()
|
||||
webhook_needed = (
|
||||
settings.TRIBUTE_ENABLED
|
||||
or settings.is_cryptobot_enabled()
|
||||
or settings.is_mulenpay_enabled()
|
||||
)
|
||||
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
@@ -134,7 +150,13 @@ async def main():
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info("💳 Запуск PayPalych webhook сервера...")
|
||||
pal24_server = await start_pal24_webhook_server(payment_service)
|
||||
else:
|
||||
logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается")
|
||||
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
@@ -160,14 +182,24 @@ async def main():
|
||||
if webhook_needed:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info(
|
||||
f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
logger.info("📄 Активные фоновые сервисы:")
|
||||
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
logger.info(
|
||||
" Отчеты: %s",
|
||||
"Включен" if reporting_service.is_running() else "Отключен",
|
||||
)
|
||||
logger.info("=" * 50)
|
||||
|
||||
try:
|
||||
@@ -235,6 +267,10 @@ async def main():
|
||||
await monitoring_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if pal24_server:
|
||||
logger.info("ℹ️ Остановка PayPalych webhook сервера...")
|
||||
await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop)
|
||||
|
||||
if maintenance_task and not maintenance_task.done():
|
||||
logger.info("ℹ️ Остановка службы техработ...")
|
||||
@@ -253,6 +289,12 @@ async def main():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса отчетов...")
|
||||
try:
|
||||
await reporting_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса отчетов: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "4b6b0f58c8f9"
|
||||
down_revision: Union[str, None] = "1f5f3a3f5a4d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name if bind else ""
|
||||
|
||||
op.add_column(
|
||||
"promo_groups",
|
||||
sa.Column("period_discounts", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL")
|
||||
else:
|
||||
op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("promo_groups", "period_discounts")
|
||||
@@ -32,3 +32,6 @@ qrcode[pil]==7.4.2
|
||||
packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Вебхуки PayPalych (Flask)
|
||||
Flask==3.1.0
|
||||
|
||||
Reference in New Issue
Block a user