Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd |
@@ -36,6 +36,10 @@ FREEKASSA_ENABLED=True #
|
|||||||
STARS_ENABLED=True # Turn on STARS
|
STARS_ENABLED=True # Turn on STARS
|
||||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||||
|
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||||
|
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||||
|
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
|
||||||
|
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
|
||||||
|
|
||||||
# YooKassa Payment Gateway Configuration
|
# YooKassa Payment Gateway Configuration
|
||||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||||
@@ -44,6 +48,7 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
|||||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||||
YOOKASSA_VAT_CODE=1 # VAT code
|
YOOKASSA_VAT_CODE=1 # VAT code
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||||
|
|
||||||
# FreeKassa Payment Gateway Configuration
|
# FreeKassa Payment Gateway Configuration
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
@@ -58,6 +63,21 @@ CRYPTOPAY_NETWORK=mainnet #
|
|||||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||||
|
|
||||||
|
# Platega Payment Gateway Configuration
|
||||||
|
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||||
|
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||||
|
PLATEGA_SECRET= # API secret from Platega
|
||||||
|
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
|
||||||
|
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||||
|
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||||
|
|
||||||
|
# SeverPay Payment Gateway Configuration
|
||||||
|
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||||
|
SEVERPAY_MID= # Your MID from SeverPay
|
||||||
|
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||||
|
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||||
|
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||||
|
|
||||||
# Tribute Payment Gateway Configuration
|
# Tribute Payment Gateway Configuration
|
||||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||||
@@ -84,6 +104,10 @@ RUB_PRICE_12_MONTHS=900
|
|||||||
STARS_PRICE_12_MONTHS=0
|
STARS_PRICE_12_MONTHS=0
|
||||||
TRIBUTE_LINK_12_MONTHS=
|
TRIBUTE_LINK_12_MONTHS=
|
||||||
|
|
||||||
|
# Traffic Packages (enables traffic sale mode when set)
|
||||||
|
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||||
|
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||||
|
|
||||||
# Subscription Notifications
|
# Subscription Notifications
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||||
@@ -92,6 +116,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Da
|
|||||||
|
|
||||||
|
|
||||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||||
|
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||||
# Referral Bonus Days
|
# Referral Bonus Days
|
||||||
# Bonus for the inviting user
|
# Bonus for the inviting user
|
||||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||||
@@ -116,6 +141,8 @@ USER_TRAFFIC_STRATEGY="NO_RESET" # Tr
|
|||||||
|
|
||||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||||
|
# Default External Squad for Users (Optional, single UUID)
|
||||||
|
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||||
|
|
||||||
# Trial Settings
|
# Trial Settings
|
||||||
TRIAL_ENABLED=True # Enable the trial period
|
TRIAL_ENABLED=True # Enable the trial period
|
||||||
@@ -123,6 +150,10 @@ TRIAL_DURATION_DAYS=5 # Du
|
|||||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||||
|
|
||||||
|
# Connection link handling (happ crypt4)
|
||||||
|
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||||
|
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||||
|
|
||||||
# Web Server Settings (for handling webhooks)
|
# Web Server Settings (for handling webhooks)
|
||||||
WEB_SERVER_HOST="0.0.0.0"
|
WEB_SERVER_HOST="0.0.0.0"
|
||||||
WEB_SERVER_PORT=8080
|
WEB_SERVER_PORT=8080
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||||
|
|
||||||
### Для администраторов:
|
### Для администраторов:
|
||||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||||
|
|
||||||
@@ -80,9 +80,12 @@
|
|||||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||||
|
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
|
||||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
@@ -93,7 +96,18 @@
|
|||||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
|
||||||
|
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
|
||||||
|
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
|
||||||
|
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
|
||||||
|
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
|
||||||
|
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
|
||||||
|
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
|
||||||
|
| `SEVERPAY_MID` | MID магазина в SeverPay. |
|
||||||
|
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
|
||||||
|
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
|
||||||
|
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
|
||||||
|
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -103,7 +117,6 @@
|
|||||||
- `1_MONTH_ENABLED`: `true` или `false`
|
- `1_MONTH_ENABLED`: `true` или `false`
|
||||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
|
||||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -116,11 +129,12 @@
|
|||||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||||
|
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
|
||||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||||
|
|
||||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||||
</gidetails>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Настройки пробного периода</b></summary>
|
<summary><b>Настройки пробного периода</b></summary>
|
||||||
@@ -139,15 +153,16 @@
|
|||||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||||
|
|
||||||
4. **Настройка вебхуков (Обязательно):**
|
4. **Настройка вебхуков (Обязательно):**
|
||||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||||
|
|
||||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||||
|
|
||||||
**Пути для перенаправления:**
|
**Пути для перенаправления:**
|
||||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||||
|
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||||
|
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
|
||||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ from bot.services.subscription_service import SubscriptionService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.stars_service import StarsService
|
from bot.services.stars_service import StarsService
|
||||||
from bot.services.tribute_service import TributeService
|
|
||||||
from bot.services.crypto_pay_service import CryptoPayService
|
from bot.services.crypto_pay_service import CryptoPayService
|
||||||
from bot.services.panel_webhook_service import PanelWebhookService
|
from bot.services.panel_webhook_service import PanelWebhookService
|
||||||
from bot.services.freekassa_service import FreeKassaService
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
|
from bot.services.platega_service import PlategaService
|
||||||
|
from bot.services.severpay_service import SeverPayService
|
||||||
|
|
||||||
|
|
||||||
def build_core_services(
|
def build_core_services(
|
||||||
@@ -45,14 +46,23 @@ def build_core_services(
|
|||||||
subscription_service=subscription_service,
|
subscription_service=subscription_service,
|
||||||
referral_service=referral_service,
|
referral_service=referral_service,
|
||||||
)
|
)
|
||||||
tribute_service = TributeService(
|
platega_service = PlategaService(
|
||||||
bot,
|
bot=bot,
|
||||||
settings,
|
settings=settings,
|
||||||
i18n,
|
i18n=i18n,
|
||||||
async_session_factory,
|
async_session_factory=async_session_factory,
|
||||||
panel_service,
|
subscription_service=subscription_service,
|
||||||
subscription_service,
|
referral_service=referral_service,
|
||||||
referral_service,
|
default_return_url=bot_username_for_default_return,
|
||||||
|
)
|
||||||
|
severpay_service = SeverPayService(
|
||||||
|
bot=bot,
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
async_session_factory=async_session_factory,
|
||||||
|
subscription_service=subscription_service,
|
||||||
|
referral_service=referral_service,
|
||||||
|
default_return_url=bot_username_for_default_return,
|
||||||
)
|
)
|
||||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||||
yookassa_service = YooKassaService(
|
yookassa_service = YooKassaService(
|
||||||
@@ -80,8 +90,8 @@ def build_core_services(
|
|||||||
"stars_service": stars_service,
|
"stars_service": stars_service,
|
||||||
"cryptopay_service": cryptopay_service,
|
"cryptopay_service": cryptopay_service,
|
||||||
"freekassa_service": freekassa_service,
|
"freekassa_service": freekassa_service,
|
||||||
"tribute_service": tribute_service,
|
|
||||||
"panel_webhook_service": panel_webhook_service,
|
"panel_webhook_service": panel_webhook_service,
|
||||||
"yookassa_service": yookassa_service,
|
"yookassa_service": yookassa_service,
|
||||||
|
"platega_service": platega_service,
|
||||||
|
"severpay_service": severpay_service,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ async def build_and_start_web_app(
|
|||||||
"stars_service",
|
"stars_service",
|
||||||
"freekassa_service",
|
"freekassa_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
"tribute_service",
|
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
|
"platega_service",
|
||||||
|
"severpay_service",
|
||||||
):
|
):
|
||||||
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
||||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||||
@@ -48,15 +49,11 @@ async def build_and_start_web_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bot.handlers.user.payment import yookassa_webhook_route
|
from bot.handlers.user.payment import yookassa_webhook_route
|
||||||
from bot.services.tribute_service import tribute_webhook_route
|
|
||||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||||
from bot.services.panel_webhook_service import panel_webhook_route
|
from bot.services.panel_webhook_service import panel_webhook_route
|
||||||
from bot.services.freekassa_service import freekassa_webhook_route
|
from bot.services.freekassa_service import freekassa_webhook_route
|
||||||
|
from bot.services.platega_service import platega_webhook_route
|
||||||
tribute_path = settings.tribute_webhook_path
|
from bot.services.severpay_service import severpay_webhook_route
|
||||||
if tribute_path.startswith("/"):
|
|
||||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
|
||||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
|
||||||
|
|
||||||
cp_path = settings.cryptopay_webhook_path
|
cp_path = settings.cryptopay_webhook_path
|
||||||
if cp_path.startswith("/"):
|
if cp_path.startswith("/"):
|
||||||
@@ -68,6 +65,16 @@ async def build_and_start_web_app(
|
|||||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||||
|
|
||||||
|
pg_path = settings.platega_webhook_path
|
||||||
|
if pg_path.startswith("/"):
|
||||||
|
app.router.add_post(pg_path, platega_webhook_route)
|
||||||
|
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||||
|
|
||||||
|
sp_path = settings.severpay_webhook_path
|
||||||
|
if sp_path.startswith("/"):
|
||||||
|
app.router.add_post(sp_path, severpay_webhook_route)
|
||||||
|
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||||
|
|
||||||
# YooKassa webhook (register only when base URL present and path configured)
|
# YooKassa webhook (register only when base URL present and path configured)
|
||||||
yk_path = settings.yookassa_webhook_path
|
yk_path = settings.yookassa_webhook_path
|
||||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||||
@@ -94,4 +101,3 @@ async def build_and_start_web_app(
|
|||||||
|
|
||||||
# Run until cancelled
|
# Run until cancelled
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
|||||||
|
|
||||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||||
if not camp:
|
if not camp:
|
||||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||||
@@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
|||||||
|
|
||||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||||
if not existed:
|
if not existed:
|
||||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||||
return
|
return
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ async def process_broadcast_message_handler(
|
|||||||
await message.answer(
|
await message.answer(
|
||||||
_(
|
_(
|
||||||
"admin_broadcast_invalid_html",
|
"admin_broadcast_invalid_html",
|
||||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler(
|
|||||||
total_failed = failed_count + dynamic_failed
|
total_failed = failed_count + dynamic_failed
|
||||||
return _(
|
return _(
|
||||||
"broadcast_queue_result",
|
"broadcast_queue_result",
|
||||||
default=(
|
|
||||||
"🚀 Рассылка поставлена в очередь!\n"
|
|
||||||
"📤 В очередь добавлено: {sent_count}\n"
|
|
||||||
"❌ Ошибок: {failed_count}\n\n"
|
|
||||||
"📊 Статус очередей:\n"
|
|
||||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
|
||||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
|
||||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
|
||||||
),
|
|
||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=total_failed,
|
failed_count=total_failed,
|
||||||
user_queue_size=stats["user_queue_size"],
|
user_queue_size=stats["user_queue_size"],
|
||||||
|
|||||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
|||||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "users_management":
|
elif action == "users_management":
|
||||||
|
# This is deprecated, kept for compatibility
|
||||||
from . import user_management as admin_user_management_handlers
|
from . import user_management as admin_user_management_handlers
|
||||||
await admin_user_management_handlers.user_management_menu_handler(
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
|
callback, state, i18n_data, settings, session)
|
||||||
|
elif action == "users_list" and len(action_parts) > 2:
|
||||||
|
# Route to users list handler with page number
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
try:
|
||||||
|
page = int(action_parts[2])
|
||||||
|
await admin_user_management_handlers.users_list_handler(
|
||||||
|
callback, i18n_data, settings, session, page)
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid page number", show_alert=True)
|
||||||
|
elif action == "users_search_prompt":
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "view_banned":
|
elif action == "view_banned":
|
||||||
|
|
||||||
|
|||||||
@@ -338,8 +338,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_logs_csv_export_started",
|
"admin_logs_csv_export_started"
|
||||||
default="🔄 Начинаю экспорт логов в CSV..."
|
|
||||||
))
|
))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -349,8 +348,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
if not logs_models:
|
if not logs_models:
|
||||||
await callback.message.answer(_(
|
await callback.message.answer(_(
|
||||||
"admin_logs_csv_no_data",
|
"admin_logs_csv_no_data"
|
||||||
default="❌ Нет данных для экспорта"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -360,16 +358,16 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
# Write header
|
# Write header
|
||||||
headers = [
|
headers = [
|
||||||
_("admin_csv_header_log_id", default="Log ID"),
|
_("admin_csv_header_log_id"),
|
||||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
_("admin_csv_header_timestamp"),
|
||||||
_("admin_csv_header_user_id", default="User ID"),
|
_("admin_csv_header_user_id"),
|
||||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
_("admin_csv_header_telegram_username"),
|
||||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
_("admin_csv_header_telegram_first_name"),
|
||||||
_("admin_csv_header_event_type", default="Event Type"),
|
_("admin_csv_header_event_type"),
|
||||||
_("admin_csv_header_content", default="Content"),
|
_("admin_csv_header_content"),
|
||||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
_("admin_csv_header_is_admin_event"),
|
||||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
_("admin_csv_header_target_user_id"),
|
||||||
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
|
_("admin_csv_header_raw_update_preview")
|
||||||
]
|
]
|
||||||
csv_writer.writerow(headers)
|
csv_writer.writerow(headers)
|
||||||
|
|
||||||
@@ -414,7 +412,6 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
|||||||
csv_file,
|
csv_file,
|
||||||
caption=_(
|
caption=_(
|
||||||
"admin_logs_csv_export_success",
|
"admin_logs_csv_export_success",
|
||||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
|
||||||
count=len(logs_models),
|
count=len(logs_models),
|
||||||
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
)
|
)
|
||||||
@@ -424,6 +421,5 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
|||||||
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
||||||
await callback.message.answer(_(
|
await callback.message.answer(_(
|
||||||
"admin_logs_csv_export_failed",
|
"admin_logs_csv_export_failed",
|
||||||
default="❌ Ошибка при экспорте логов: {error}",
|
|
||||||
error=str(e)
|
error=str(e)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -34,12 +34,20 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
|
|||||||
return payments, total_count
|
return payments, total_count
|
||||||
|
|
||||||
|
|
||||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
|
||||||
"""Format single payment info as text."""
|
"""Format single payment info as text."""
|
||||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
pending_statuses = [
|
||||||
|
'pending',
|
||||||
|
'pending_yookassa',
|
||||||
|
'pending_freekassa',
|
||||||
|
'pending_platega',
|
||||||
|
'pending_severpay',
|
||||||
|
'pending_cryptopay',
|
||||||
|
]
|
||||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
"⏳" if payment.status in pending_statuses else "❌"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_info = f"User {payment.user_id}"
|
user_info = f"User {payment.user_id}"
|
||||||
@@ -52,17 +60,27 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
|||||||
|
|
||||||
provider_text = {
|
provider_text = {
|
||||||
'yookassa': 'YooKassa',
|
'yookassa': 'YooKassa',
|
||||||
'tribute': 'Tribute',
|
|
||||||
'telegram_stars': 'Telegram Stars',
|
'telegram_stars': 'Telegram Stars',
|
||||||
'cryptopay': 'CryptoPay',
|
'cryptopay': 'CryptoPay',
|
||||||
'freekassa': 'FreeKassa',
|
'freekassa': 'FreeKassa',
|
||||||
|
'severpay': 'SeverPay',
|
||||||
|
'platega': 'Platega',
|
||||||
}.get(payment.provider, payment.provider or 'Unknown')
|
}.get(payment.provider, payment.provider or 'Unknown')
|
||||||
|
|
||||||
|
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||||
|
if traffic_mode:
|
||||||
|
traffic_val = payment.subscription_duration_months or 0
|
||||||
|
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
|
||||||
|
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
|
||||||
|
else:
|
||||||
|
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
||||||
f"👤 {user_info}\n"
|
f"👤 {user_info}\n"
|
||||||
f"💳 {provider_text}\n"
|
f"💳 {provider_text}\n"
|
||||||
f"📅 {payment_date}\n"
|
f"📅 {payment_date}\n"
|
||||||
|
f"{period_line}\n"
|
||||||
f"📋 {payment.status}\n"
|
f"📋 {payment.status}\n"
|
||||||
f"📝 {payment.description or 'N/A'}"
|
f"📝 {payment.description or 'N/A'}"
|
||||||
)
|
)
|
||||||
@@ -84,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
|||||||
|
|
||||||
if not payments and page == 0:
|
if not payments and page == 0:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("admin_no_payments_found", default="Платежи не найдены."),
|
_("admin_no_payments_found"),
|
||||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
@@ -92,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Format payments text
|
# Format payments text
|
||||||
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
|
text_parts = [_("admin_payments_header")]
|
||||||
text_parts.append(_("admin_payments_pagination_info",
|
text_parts.append(_("admin_payments_pagination_info",
|
||||||
shown=len(payments),
|
shown=len(payments),
|
||||||
total=total_count,
|
total=total_count,
|
||||||
@@ -100,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
|||||||
total_pages=total_pages) + "\n")
|
total_pages=total_pages) + "\n")
|
||||||
|
|
||||||
for i, payment in enumerate(payments, 1):
|
for i, payment in enumerate(payments, 1):
|
||||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
|
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
|
||||||
text_parts.append("") # Empty line between payments
|
text_parts.append("") # Empty line between payments
|
||||||
|
|
||||||
# Build keyboard with pagination and export
|
# Build keyboard with pagination and export
|
||||||
@@ -122,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
|||||||
# Export and refresh buttons
|
# Export and refresh buttons
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
|
text=_("admin_export_payments_csv"),
|
||||||
callback_data="payments_export_csv"
|
callback_data="payments_export_csv"
|
||||||
),
|
),
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_refresh_payments", default="🔄 Обновить"),
|
text=_("admin_refresh_payments"),
|
||||||
callback_data=f"payments_page:{page}"
|
callback_data=f"payments_page:{page}"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -173,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
|
|
||||||
if not all_payments:
|
if not all_payments:
|
||||||
await callback.answer(
|
await callback.answer(
|
||||||
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
|
_("admin_no_payments_to_export"),
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -184,22 +202,30 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
|
|
||||||
# Write header
|
# Write header
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
_("admin_csv_payment_id", default="ID"),
|
_("admin_csv_payment_id"),
|
||||||
_("admin_csv_user_id", default="User ID"),
|
_("admin_csv_user_id"),
|
||||||
_("admin_csv_username", default="Username"),
|
_("admin_csv_username"),
|
||||||
_("admin_csv_first_name", default="First Name"),
|
_("admin_csv_first_name"),
|
||||||
_("admin_csv_amount", default="Amount"),
|
_("admin_csv_amount"),
|
||||||
_("admin_csv_currency", default="Currency"),
|
_("admin_csv_currency"),
|
||||||
_("admin_csv_provider", default="Provider"),
|
_("admin_csv_provider"),
|
||||||
_("admin_csv_status", default="Status"),
|
_("admin_csv_status"),
|
||||||
_("admin_csv_description", default="Description"),
|
_("admin_csv_description"),
|
||||||
_("admin_csv_months", default="Months"),
|
_("admin_csv_units"),
|
||||||
_("admin_csv_created_at", default="Created At"),
|
_("admin_csv_created_at"),
|
||||||
_("admin_csv_provider_payment_id", default="Provider Payment ID")
|
_("admin_csv_provider_payment_id")
|
||||||
])
|
])
|
||||||
|
|
||||||
|
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||||
|
|
||||||
# Write payment data
|
# Write payment data
|
||||||
for payment in all_payments:
|
for payment in all_payments:
|
||||||
|
units_val = payment.subscription_duration_months or ""
|
||||||
|
if traffic_mode and units_val not in ("", None):
|
||||||
|
try:
|
||||||
|
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||||
|
except Exception:
|
||||||
|
units_val = payment.subscription_duration_months or ""
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
payment.user_id,
|
payment.user_id,
|
||||||
@@ -210,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
payment.provider or "",
|
payment.provider or "",
|
||||||
payment.status,
|
payment.status,
|
||||||
payment.description or "",
|
payment.description or "",
|
||||||
payment.subscription_duration_months or "",
|
units_val,
|
||||||
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
||||||
payment.provider_payment_id or ""
|
payment.provider_payment_id or ""
|
||||||
])
|
])
|
||||||
@@ -230,12 +256,11 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
await callback.message.reply_document(
|
await callback.message.reply_document(
|
||||||
document=file,
|
document=file,
|
||||||
caption=_("admin_payments_export_success",
|
caption=_("admin_payments_export_success",
|
||||||
default="📊 Payments export completed!\nTotal records: {count}",
|
|
||||||
count=len(all_payments))
|
count=len(all_payments))
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer(
|
await callback.answer(
|
||||||
_("admin_export_sent", default="File sent!"),
|
_("admin_export_sent"),
|
||||||
show_alert=False
|
show_alert=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
# Step 1: Ask for quantity
|
# Step 1: Ask for quantity
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_bulk_promo_step1_quantity",
|
"admin_bulk_promo_step1_quantity"
|
||||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
|||||||
quantity = int(message.text.strip())
|
quantity = int(message.text.strip())
|
||||||
if not (1 <= quantity <= 100):
|
if not (1 <= quantity <= 100):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_bulk_promo_invalid_quantity",
|
"admin_bulk_promo_invalid_quantity"
|
||||||
default="❌ Количество промокодов должно быть от 1 до 100"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
|||||||
# Step 2: Ask for bonus days
|
# Step 2: Ask for bonus days
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_bulk_promo_step2_bonus_days",
|
"admin_bulk_promo_step2_bonus_days",
|
||||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
|
|
||||||
quantity=quantity
|
quantity=quantity
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing bulk promo quantity: {e}")
|
logging.error(f"Error processing bulk promo quantity: {e}")
|
||||||
@@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
|||||||
bonus_days = int(message.text.strip())
|
bonus_days = int(message.text.strip())
|
||||||
if not (1 <= bonus_days <= 365):
|
if not (1 <= bonus_days <= 365):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_bonus_days",
|
"admin_promo_invalid_bonus_days"
|
||||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_bulk_promo_step3_max_activations",
|
"admin_bulk_promo_step3_max_activations",
|
||||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
|
||||||
quantity=data.get("quantity"),
|
quantity=data.get("quantity"),
|
||||||
bonus_days=bonus_days
|
bonus_days=bonus_days
|
||||||
)
|
)
|
||||||
@@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing bulk promo bonus days: {e}")
|
logging.error(f"Error processing bulk promo bonus days: {e}")
|
||||||
@@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
|||||||
max_activations = int(message.text.strip())
|
max_activations = int(message.text.strip())
|
||||||
if not (1 <= max_activations <= 10000):
|
if not (1 <= max_activations <= 10000):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_max_activations",
|
"admin_promo_invalid_max_activations"
|
||||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_bulk_promo_step4_validity",
|
"admin_bulk_promo_step4_validity",
|
||||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
|
||||||
quantity=data.get("quantity"),
|
quantity=data.get("quantity"),
|
||||||
bonus_days=data.get("bonus_days"),
|
bonus_days=data.get("bonus_days"),
|
||||||
max_activations=max_activations
|
max_activations=max_activations
|
||||||
@@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
text=_("admin_promo_unlimited_validity"),
|
||||||
callback_data="bulk_promo_unlimited_validity"
|
callback_data="bulk_promo_unlimited_validity"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
text=_("admin_promo_set_validity_days"),
|
||||||
callback_data="bulk_promo_set_validity"
|
callback_data="bulk_promo_set_validity"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
text=_("admin_back_to_panel"),
|
||||||
callback_data="admin_action:main"
|
callback_data="admin_action:main"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing bulk promo max activations: {e}")
|
logging.error(f"Error processing bulk promo max activations: {e}")
|
||||||
@@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_bulk_promo_enter_validity_days",
|
"admin_bulk_promo_enter_validity_days",
|
||||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
|
|
||||||
quantity=data.get("quantity"),
|
quantity=data.get("quantity"),
|
||||||
bonus_days=data.get("bonus_days"),
|
bonus_days=data.get("bonus_days"),
|
||||||
max_activations=data.get("max_activations")
|
max_activations=data.get("max_activations")
|
||||||
@@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
|||||||
validity_days = int(message.text.strip())
|
validity_days = int(message.text.strip())
|
||||||
if not (1 <= validity_days <= 365):
|
if not (1 <= validity_days <= 365):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_validity_days",
|
"admin_promo_invalid_validity_days"
|
||||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing bulk promo validity days: {e}")
|
logging.error(f"Error processing bulk promo validity days: {e}")
|
||||||
@@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
|||||||
# Show progress message
|
# Show progress message
|
||||||
progress_text = _(
|
progress_text = _(
|
||||||
"admin_bulk_promo_creating",
|
"admin_bulk_promo_creating",
|
||||||
default="🔄 Создание {quantity} промокодов...",
|
|
||||||
quantity=quantity
|
quantity=quantity
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
|||||||
# Success message
|
# Success message
|
||||||
success_lines = [
|
success_lines = [
|
||||||
_(
|
_(
|
||||||
"admin_bulk_promo_created_title",
|
"admin_bulk_promo_created_title"
|
||||||
default="✅ <b>Массовое создание завершено!</b>\n"
|
|
||||||
),
|
),
|
||||||
_(
|
_(
|
||||||
"admin_bulk_promo_created_stats",
|
"admin_bulk_promo_created_stats",
|
||||||
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
|
||||||
created=len(created_codes),
|
created=len(created_codes),
|
||||||
total=quantity
|
total=quantity
|
||||||
)
|
)
|
||||||
@@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
|||||||
if data.get("validity_days"):
|
if data.get("validity_days"):
|
||||||
validity_text = f"{data['validity_days']} дней"
|
validity_text = f"{data['validity_days']} дней"
|
||||||
else:
|
else:
|
||||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
validity_text = _("admin_promo_unlimited")
|
||||||
|
|
||||||
success_lines.append(
|
success_lines.append(
|
||||||
_(
|
_(
|
||||||
"admin_bulk_promo_settings",
|
"admin_bulk_promo_settings",
|
||||||
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
|
||||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
|
||||||
"⏰ Срок действия: <b>{validity}</b>",
|
|
||||||
bonus_days=data["bonus_days"],
|
bonus_days=data["bonus_days"],
|
||||||
max_activations=data["max_activations"],
|
max_activations=data["max_activations"],
|
||||||
validity=validity_text
|
validity=validity_text
|
||||||
@@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error creating bulk promo codes: {e}")
|
logging.error(f"Error creating bulk promo codes: {e}")
|
||||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
error_text = _("error_occurred_try_again")
|
||||||
|
|
||||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||||
await callback_or_message.message.answer(error_text)
|
await callback_or_message.message.answer(error_text)
|
||||||
@@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
|
|||||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
|
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -30,8 +30,7 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
# Step 1: Ask for promo code
|
# Step 1: Ask for promo code
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_promo_step1_code",
|
"admin_promo_step1_code"
|
||||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -68,8 +67,7 @@ async def process_promo_code_handler(message: types.Message,
|
|||||||
code_str = message.text.strip().upper()
|
code_str = message.text.strip().upper()
|
||||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_code_format",
|
"admin_promo_invalid_code_format"
|
||||||
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -77,8 +75,7 @@ async def process_promo_code_handler(message: types.Message,
|
|||||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
||||||
if existing_promo:
|
if existing_promo:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_code_already_exists",
|
"admin_promo_code_already_exists"
|
||||||
default="❌ Промокод с таким кодом уже существует"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -87,7 +84,6 @@ async def process_promo_code_handler(message: types.Message,
|
|||||||
# Step 2: Ask for bonus days
|
# Step 2: Ask for bonus days
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_promo_step2_bonus_days",
|
"admin_promo_step2_bonus_days",
|
||||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
|
||||||
code=code_str
|
code=code_str
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,8 +116,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
|||||||
bonus_days = int(message.text.strip())
|
bonus_days = int(message.text.strip())
|
||||||
if not (1 <= bonus_days <= 365):
|
if not (1 <= bonus_days <= 365):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_bonus_days",
|
"admin_promo_invalid_bonus_days"
|
||||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -131,7 +126,6 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_promo_step3_max_activations",
|
"admin_promo_step3_max_activations",
|
||||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
|
||||||
code=data.get("promo_code"),
|
code=data.get("promo_code"),
|
||||||
bonus_days=bonus_days
|
bonus_days=bonus_days
|
||||||
)
|
)
|
||||||
@@ -145,8 +139,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing promo bonus days: {e}")
|
logging.error(f"Error processing promo bonus days: {e}")
|
||||||
@@ -170,8 +163,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
|||||||
max_activations = int(message.text.strip())
|
max_activations = int(message.text.strip())
|
||||||
if not (1 <= max_activations <= 10000):
|
if not (1 <= max_activations <= 10000):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_max_activations",
|
"admin_promo_invalid_max_activations"
|
||||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -181,7 +173,6 @@ async def process_promo_max_activations_handler(message: types.Message,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_promo_step4_validity",
|
"admin_promo_step4_validity",
|
||||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
|
||||||
code=data.get("promo_code"),
|
code=data.get("promo_code"),
|
||||||
bonus_days=data.get("bonus_days"),
|
bonus_days=data.get("bonus_days"),
|
||||||
max_activations=max_activations
|
max_activations=max_activations
|
||||||
@@ -191,19 +182,19 @@ async def process_promo_max_activations_handler(message: types.Message,
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
text=_("admin_promo_unlimited_validity"),
|
||||||
callback_data="promo_unlimited_validity"
|
callback_data="promo_unlimited_validity"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
text=_("admin_promo_set_validity_days"),
|
||||||
callback_data="promo_set_validity"
|
callback_data="promo_set_validity"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
text=_("admin_back_to_panel"),
|
||||||
callback_data="admin_action:main"
|
callback_data="admin_action:main"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -217,8 +208,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing promo max activations: {e}")
|
logging.error(f"Error processing promo max activations: {e}")
|
||||||
@@ -252,7 +242,6 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_promo_enter_validity_days",
|
"admin_promo_enter_validity_days",
|
||||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
|
|
||||||
code=data.get("promo_code"),
|
code=data.get("promo_code"),
|
||||||
bonus_days=data.get("bonus_days"),
|
bonus_days=data.get("bonus_days"),
|
||||||
max_activations=data.get("max_activations")
|
max_activations=data.get("max_activations")
|
||||||
@@ -291,8 +280,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
|||||||
validity_days = int(message.text.strip())
|
validity_days = int(message.text.strip())
|
||||||
if not (1 <= validity_days <= 365):
|
if not (1 <= validity_days <= 365):
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_validity_days",
|
"admin_promo_invalid_validity_days"
|
||||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -301,8 +289,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_promo_invalid_number",
|
"admin_promo_invalid_number"
|
||||||
default="❌ Введите корректное число"
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing promo validity days: {e}")
|
logging.error(f"Error processing promo validity days: {e}")
|
||||||
@@ -349,14 +336,9 @@ async def create_promo_code_final(callback_or_message,
|
|||||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||||
success_text = _(
|
success_text = _(
|
||||||
"admin_promo_created_success",
|
"admin_promo_created_success",
|
||||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
|
||||||
"🎟 Код: <code>{code}</code>\n"
|
|
||||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
|
||||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
|
||||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
|
||||||
code=data["promo_code"],
|
code=data["promo_code"],
|
||||||
bonus_days=data["bonus_days"],
|
bonus_days=data["bonus_days"],
|
||||||
max_activations=data["max_activations"],
|
max_activations=data["max_activations"],
|
||||||
@@ -388,7 +370,7 @@ async def create_promo_code_final(callback_or_message,
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error creating promo code: {e}")
|
logging.error(f"Error creating promo code: {e}")
|
||||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
error_text = _("error_occurred_try_again")
|
||||||
|
|
||||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||||
await callback_or_message.message.answer(error_text)
|
await callback_or_message.message.answer(error_text)
|
||||||
@@ -432,5 +414,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
|||||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
|
await callback.answer(_("admin_promo_creation_cancelled"))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -34,30 +34,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||||
|
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
|
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||||
)
|
)
|
||||||
# Removed: Active today moved to panel stats
|
# Removed: Active today moved to panel stats
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
|
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
|
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
|
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
|
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
|
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Panel Statistics - moved above financial
|
# Panel Statistics - moved above financial
|
||||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
|
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with PanelApiService(settings) as panel_service:
|
async with PanelApiService(settings) as panel_service:
|
||||||
@@ -80,12 +80,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
total_users = users.get('totalUsers', 0)
|
total_users = users.get('totalUsers', 0)
|
||||||
online_now = online_stats.get('onlineNow', 0)
|
online_now = online_stats.get('onlineNow', 0)
|
||||||
|
|
||||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
|
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
|
||||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
|
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
|
||||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
|
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
|
||||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
|
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label')}: <b>{expired_users}</b>")
|
||||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
|
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
|
||||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
|
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
|
||||||
|
|
||||||
# System resources
|
# System resources
|
||||||
memory = system_stats.get('memory', {})
|
memory = system_stats.get('memory', {})
|
||||||
@@ -93,9 +93,9 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
memory_total = memory.get('total', 1)
|
memory_total = memory.get('total', 1)
|
||||||
memory_used = memory.get('used', 0)
|
memory_used = memory.get('used', 0)
|
||||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
|
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
|
||||||
else:
|
else:
|
||||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
|
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
|
||||||
|
|
||||||
# Bandwidth stats
|
# Bandwidth stats
|
||||||
if bandwidth_stats:
|
if bandwidth_stats:
|
||||||
@@ -107,13 +107,13 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
if week_traffic:
|
if week_traffic:
|
||||||
week_total = week_traffic.get('current', '0 B')
|
week_total = week_traffic.get('current', '0 B')
|
||||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
|
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
|
||||||
|
|
||||||
if month_traffic:
|
if month_traffic:
|
||||||
month_total = month_traffic.get('current', '0 B')
|
month_total = month_traffic.get('current', '0 B')
|
||||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
|
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
|
||||||
else:
|
else:
|
||||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
|
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
|
||||||
|
|
||||||
# Nodes stats
|
# Nodes stats
|
||||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||||
@@ -124,35 +124,35 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
unique_nodes.add(node_data.get('nodeName', ''))
|
unique_nodes.add(node_data.get('nodeName', ''))
|
||||||
total_nodes_count = len(unique_nodes)
|
total_nodes_count = len(unique_nodes)
|
||||||
# Assume all nodes are active since we don't have status info
|
# Assume all nodes are active since we don't have status info
|
||||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||||
else:
|
else:
|
||||||
# Use nodes total from system stats as fallback
|
# Use nodes total from system stats as fallback
|
||||||
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
||||||
total_online = nodes_info.get('totalOnline', 0)
|
total_online = nodes_info.get('totalOnline', 0)
|
||||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
|
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
||||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
|
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error')}")
|
||||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
|
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
|
||||||
|
|
||||||
# Financial statistics
|
# Financial statistics
|
||||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||||
|
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
|
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||||
)
|
)
|
||||||
|
|
||||||
last_payments_models: List[
|
last_payments_models: List[
|
||||||
@@ -162,8 +162,21 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||||
for payment in last_payments_models:
|
for payment in last_payments_models:
|
||||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
pending_statuses = [
|
||||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
"pending",
|
||||||
|
"pending_yookassa",
|
||||||
|
"pending_freekassa",
|
||||||
|
"pending_platega",
|
||||||
|
"pending_severpay",
|
||||||
|
"pending_cryptopay",
|
||||||
|
]
|
||||||
|
status_emoji = (
|
||||||
|
"✅"
|
||||||
|
if payment.status == "succeeded"
|
||||||
|
else "⏳"
|
||||||
|
if payment.status in pending_statuses
|
||||||
|
else "❌"
|
||||||
|
)
|
||||||
|
|
||||||
user_info = f"User {payment.user_id}"
|
user_info = f"User {payment.user_id}"
|
||||||
if payment.user and payment.user.username:
|
if payment.user and payment.user.username:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import update, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -10,14 +11,19 @@ from bot.services.panel_api_service import PanelApiService
|
|||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||||
|
from db.models import Subscription
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="admin_sync_router")
|
router = Router(name="admin_sync_router")
|
||||||
|
|
||||||
|
|
||||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
async def perform_sync(
|
||||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_instance: JsonI18n,
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform panel synchronization and return results
|
Perform panel synchronization and return results
|
||||||
Returns dict with status, details, and sync statistics
|
Returns dict with status, details, and sync statistics
|
||||||
@@ -52,7 +58,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
session, "success", status_msg, 0, 0
|
session, "success", status_msg, 0, 0
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"details": status_msg,
|
||||||
|
"users_synced": 0,
|
||||||
|
"subs_synced": 0,
|
||||||
|
}
|
||||||
|
|
||||||
total_panel_users = len(panel_users_data)
|
total_panel_users = len(panel_users_data)
|
||||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||||
@@ -61,12 +72,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
try:
|
try:
|
||||||
panel_records_checked += 1
|
panel_records_checked += 1
|
||||||
panel_uuid = panel_user_dict.get("uuid")
|
panel_uuid = panel_user_dict.get("uuid")
|
||||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
|
||||||
|
"shortUuid"
|
||||||
|
)
|
||||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||||
|
|
||||||
if not panel_uuid:
|
if not panel_uuid:
|
||||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
logging.warning(
|
||||||
|
f"Skipping panel user without UUID: {panel_user_dict}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Track users without telegram ID
|
# Track users without telegram ID
|
||||||
@@ -78,18 +93,31 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
|
|
||||||
# First, try to find by telegram ID if available
|
# First, try to find by telegram ID if available
|
||||||
if telegram_id_from_panel:
|
if telegram_id_from_panel:
|
||||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
existing_user = await user_dal.get_user_by_id(
|
||||||
|
session, telegram_id_from_panel
|
||||||
|
)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
logging.debug(
|
||||||
|
f"Found user by telegramId {telegram_id_from_panel}"
|
||||||
|
)
|
||||||
|
|
||||||
# If not found by telegram ID, try to find by panel UUID
|
# If not found by telegram ID, try to find by panel UUID
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||||
|
session, panel_uuid
|
||||||
|
)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
logging.info(
|
||||||
|
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
|
||||||
|
)
|
||||||
# Update telegram ID if it was missing in panel data but we have local user
|
# Update telegram ID if it was missing in panel data but we have local user
|
||||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
if (
|
||||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
telegram_id_from_panel
|
||||||
|
and existing_user.user_id != telegram_id_from_panel
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
users_not_found_in_db += 1
|
users_not_found_in_db += 1
|
||||||
@@ -104,22 +132,32 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"language_code": "ru", # Default language
|
"language_code": "ru", # Default language
|
||||||
"panel_user_uuid": panel_uuid,
|
"panel_user_uuid": panel_uuid,
|
||||||
"is_banned": False,
|
"is_banned": False,
|
||||||
"referred_by_id": None
|
"referred_by_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
new_user, was_created = await user_dal.create_user(
|
||||||
|
session, user_data
|
||||||
|
)
|
||||||
if was_created:
|
if was_created:
|
||||||
users_created += 1
|
users_created += 1
|
||||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
logging.info(
|
||||||
|
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
existing_user = new_user
|
existing_user = new_user
|
||||||
|
|
||||||
except Exception as e_create:
|
except Exception as e_create:
|
||||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
sync_errors.append(
|
||||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
logging.debug(
|
||||||
|
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# User found in local DB
|
# User found in local DB
|
||||||
@@ -134,20 +172,29 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
existing_user.panel_user_uuid = panel_uuid
|
existing_user.panel_user_uuid = panel_uuid
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
users_uuid_updated += 1
|
users_uuid_updated += 1
|
||||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
logging.info(
|
||||||
|
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure panel description contains Telegram fields
|
# Ensure panel description contains Telegram fields
|
||||||
try:
|
try:
|
||||||
if panel_uuid and existing_user:
|
if panel_uuid and existing_user:
|
||||||
description_text = "\n".join([
|
description_text = "\n".join(
|
||||||
|
[
|
||||||
existing_user.username or "",
|
existing_user.username or "",
|
||||||
existing_user.first_name or "",
|
existing_user.first_name or "",
|
||||||
existing_user.last_name or "",
|
existing_user.last_name or "",
|
||||||
])
|
]
|
||||||
|
)
|
||||||
# Update description only when it differs from the current one on panel
|
# Update description only when it differs from the current one on panel
|
||||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
current_panel_description = (
|
||||||
|
panel_user_dict.get("description") or ""
|
||||||
|
).strip()
|
||||||
desired_description = description_text.strip()
|
desired_description = description_text.strip()
|
||||||
if desired_description and desired_description != current_panel_description:
|
if (
|
||||||
|
desired_description
|
||||||
|
and desired_description != current_panel_description
|
||||||
|
):
|
||||||
await panel_service.update_user_details_on_panel(
|
await panel_service.update_user_details_on_panel(
|
||||||
panel_uuid, {"description": description_text}
|
panel_uuid, {"description": description_text}
|
||||||
)
|
)
|
||||||
@@ -173,6 +220,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
)
|
)
|
||||||
|
|
||||||
if subscription_uuid_from_panel:
|
if subscription_uuid_from_panel:
|
||||||
|
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
|
||||||
|
if panel_status == "ACTIVE":
|
||||||
|
await session.execute(
|
||||||
|
update(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.panel_user_uuid == panel_uuid,
|
||||||
|
Subscription.is_active.is_(True),
|
||||||
|
or_(
|
||||||
|
Subscription.panel_subscription_uuid
|
||||||
|
!= subscription_uuid_from_panel,
|
||||||
|
Subscription.panel_subscription_uuid.is_(
|
||||||
|
None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
is_active=False,
|
||||||
|
status_from_panel="INACTIVE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||||
existing_sub_by_uuid = (
|
existing_sub_by_uuid = (
|
||||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||||
@@ -197,7 +265,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_updated += 1
|
subscriptions_updated += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
|
||||||
|
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Create a new subscription only when we have a concrete subscription UUID
|
# Create a new subscription only when we have a concrete subscription UUID
|
||||||
@@ -212,6 +281,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"is_active": panel_status == "ACTIVE",
|
"is_active": panel_status == "ACTIVE",
|
||||||
"status_from_panel": panel_status,
|
"status_from_panel": panel_status,
|
||||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
created_sub = await subscription_dal.upsert_subscription(
|
created_sub = await subscription_dal.upsert_subscription(
|
||||||
session, sub_payload
|
session, sub_payload
|
||||||
@@ -220,13 +290,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_created += 1
|
subscriptions_created += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
f"Created subscription {created_sub.subscription_id} "
|
||||||
|
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
active_sub = (
|
||||||
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, actual_user_id, panel_uuid
|
session, actual_user_id, panel_uuid
|
||||||
)
|
)
|
||||||
|
)
|
||||||
if active_sub:
|
if active_sub:
|
||||||
await subscription_dal.update_subscription(
|
await subscription_dal.update_subscription(
|
||||||
session,
|
session,
|
||||||
@@ -241,7 +314,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_updated += 1
|
subscriptions_updated += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
f"Updated active subscription {active_sub.subscription_id} "
|
||||||
|
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||||
@@ -250,14 +324,20 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
sync_errors.append(
|
||||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Error syncing subscription for user {actual_user_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
if user_was_updated:
|
if user_was_updated:
|
||||||
users_updated += 1
|
users_updated += 1
|
||||||
|
|
||||||
except Exception as e_user:
|
except Exception as e_user:
|
||||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
sync_errors.append(
|
||||||
|
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
|
||||||
|
)
|
||||||
logging.error(f"Error syncing user: {e_user}")
|
logging.error(f"Error syncing user: {e_user}")
|
||||||
|
|
||||||
# Update sync status
|
# Update sync status
|
||||||
@@ -266,14 +346,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
default_lang = settings.DEFAULT_LANGUAGE
|
default_lang = settings.DEFAULT_LANGUAGE
|
||||||
additional_stats = ""
|
additional_stats = ""
|
||||||
if users_without_telegram_id > 0:
|
if users_without_telegram_id > 0:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_no_telegram_id",
|
||||||
|
count=users_without_telegram_id,
|
||||||
|
)
|
||||||
if users_not_found_in_db > 0:
|
if users_not_found_in_db > 0:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_not_found_in_db",
|
||||||
|
count=users_not_found_in_db,
|
||||||
|
)
|
||||||
if sync_errors:
|
if sync_errors:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang, "admin_sync_errors", count=len(sync_errors)
|
||||||
|
)
|
||||||
|
|
||||||
# Build full details using localization
|
# Build full details using localization
|
||||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
details = i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_details",
|
||||||
panel_records_checked=panel_records_checked,
|
panel_records_checked=panel_records_checked,
|
||||||
users_found_in_db=users_found_in_db,
|
users_found_in_db=users_found_in_db,
|
||||||
users_created=users_created,
|
users_created=users_created,
|
||||||
@@ -281,11 +373,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_synced_count=subscriptions_synced_count,
|
subscriptions_synced_count=subscriptions_synced_count,
|
||||||
subscriptions_created=subscriptions_created,
|
subscriptions_created=subscriptions_created,
|
||||||
subscriptions_updated=subscriptions_updated,
|
subscriptions_updated=subscriptions_updated,
|
||||||
additional_stats=additional_stats
|
additional_stats=additional_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
session,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
panel_records_checked,
|
||||||
|
subscriptions_synced_count,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -310,7 +406,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"users_synced": users_found_in_db,
|
"users_synced": users_found_in_db,
|
||||||
"users_created": users_created,
|
"users_created": users_created,
|
||||||
"subs_synced": subscriptions_synced_count,
|
"subs_synced": subscriptions_synced_count,
|
||||||
"errors": sync_errors
|
"errors": sync_errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e_sync_global:
|
except Exception as e_sync_global:
|
||||||
@@ -319,10 +415,18 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||||
|
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
session,
|
||||||
|
"failed",
|
||||||
|
error_detail,
|
||||||
|
panel_records_checked,
|
||||||
|
subscriptions_synced_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"details": error_detail,
|
||||||
|
"errors": [str(e_sync_global)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("sync"))
|
@router.message(Command("sync"))
|
||||||
@@ -374,7 +478,10 @@ async def sync_command_handler(
|
|||||||
if status == "failed":
|
if status == "failed":
|
||||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||||
elif status == "completed_with_errors":
|
elif status == "completed_with_errors":
|
||||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
await bot.send_message(
|
||||||
|
target_chat_id,
|
||||||
|
_("sync_errors_simple", errors_count=len(errors)),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||||
|
|
||||||
@@ -382,15 +489,18 @@ async def sync_command_handler(
|
|||||||
try:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
await notification_service.notify_panel_sync(
|
await notification_service.notify_panel_sync(
|
||||||
status, details,
|
status,
|
||||||
|
details,
|
||||||
sync_result.get("users_processed", 0),
|
sync_result.get("users_processed", 0),
|
||||||
sync_result.get("subs_synced", 0)
|
sync_result.get("subs_synced", 0),
|
||||||
)
|
)
|
||||||
except Exception as e_notification:
|
except Exception as e_notification:
|
||||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||||
|
|
||||||
except Exception as e_sync_global:
|
except Exception as e_sync_global:
|
||||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
logging.error(
|
||||||
|
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||||
|
)
|
||||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||||
|
|
||||||
# Send notification to log channel about failure
|
# Send notification to log channel about failure
|
||||||
@@ -400,7 +510,9 @@ async def sync_command_handler(
|
|||||||
"failed", str(e_sync_global), 0, 0
|
"failed", str(e_sync_global), 0, 0
|
||||||
)
|
)
|
||||||
except Exception as e_notification:
|
except Exception as e_notification:
|
||||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
logging.error(
|
||||||
|
f"Failed to send sync failure notification: {e_notification}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("syncstatus"))
|
@router.message(Command("syncstatus"))
|
||||||
@@ -419,7 +531,9 @@ async def sync_status_command_handler(
|
|||||||
if status_record_model:
|
if status_record_model:
|
||||||
last_time_val = status_record_model.last_sync_time
|
last_time_val = status_record_model.last_sync_time
|
||||||
last_time_str = (
|
last_time_str = (
|
||||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
if last_time_val
|
||||||
|
else "N/A"
|
||||||
)
|
)
|
||||||
|
|
||||||
details_val = status_record_model.details
|
details_val = status_record_model.details
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot
|
|||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.utils.markdown import hcode, hbold
|
from aiogram.utils.markdown import hcode, hbold
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -24,25 +24,70 @@ from bot.utils.text_sanitizer import (
|
|||||||
sanitize_username,
|
sanitize_username,
|
||||||
username_for_display,
|
username_for_display,
|
||||||
)
|
)
|
||||||
|
from bot.utils.telegram_markup import (
|
||||||
|
is_profile_link_error,
|
||||||
|
remove_profile_link_buttons,
|
||||||
|
)
|
||||||
|
|
||||||
router = Router(name="admin_user_management_router")
|
router = Router(name="admin_user_management_router")
|
||||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||||
|
|
||||||
|
|
||||||
async def user_management_menu_handler(callback: types.CallbackQuery,
|
async def users_list_handler(callback: types.CallbackQuery,
|
||||||
state: FSMContext, i18n_data: dict,
|
i18n_data: dict, settings: Settings,
|
||||||
settings: Settings, session: AsyncSession):
|
session: AsyncSession, page: int = 0):
|
||||||
"""Display user management menu"""
|
"""Display paginated list of all users"""
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer("Error preparing user management.", show_alert=True)
|
await callback.answer("Error preparing user list.", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get paginated users
|
||||||
|
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
|
||||||
|
from db.dal import user_dal
|
||||||
|
|
||||||
|
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
|
||||||
|
total_users = await user_dal.count_all_users(session)
|
||||||
|
total_pages = max(1, (total_users + 14) // 15)
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
header_text = _(
|
||||||
|
"admin_users_list_header",
|
||||||
|
current=page + 1,
|
||||||
|
total=total_pages,
|
||||||
|
total_users=total_users
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
header_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user list: {e}")
|
||||||
|
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_search_prompt_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, session: AsyncSession):
|
||||||
|
"""Display search prompt for user management"""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
await callback.answer("Error preparing search.", show_alert=True)
|
||||||
return
|
return
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_user_management_prompt",
|
"admin_user_management_prompt"
|
||||||
default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -61,44 +106,62 @@ async def user_management_menu_handler(callback: types.CallbackQuery,
|
|||||||
await state.set_state(AdminStates.waiting_for_user_search)
|
await state.set_state(AdminStates.waiting_for_user_search)
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder:
|
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
|
||||||
|
referrer_id: Optional[int] = None) -> InlineKeyboardBuilder:
|
||||||
"""Generate keyboard for user management actions"""
|
"""Generate keyboard for user management actions"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
# Row 1: Trial and Subscription actions
|
# Row 1: Trial and Subscription actions
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"),
|
text=_(key="admin_user_reset_trial_button"),
|
||||||
callback_data=f"user_action:reset_trial:{user_id}"
|
callback_data=f"user_action:reset_trial:{user_id}"
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_add_subscription_button", default="➕ Добавить дни"),
|
text=_(key="admin_user_add_subscription_button"),
|
||||||
callback_data=f"user_action:add_subscription:{user_id}"
|
callback_data=f"user_action:add_subscription:{user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Row 2: Block/Unblock and Message
|
# Row 2: Block/Unblock and Message
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"),
|
text=_(key="admin_user_toggle_ban_button"),
|
||||||
callback_data=f"user_action:toggle_ban:{user_id}"
|
callback_data=f"user_action:toggle_ban:{user_id}"
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"),
|
text=_(key="admin_user_send_message_button"),
|
||||||
callback_data=f"user_action:send_message:{user_id}"
|
callback_data=f"user_action:send_message:{user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Row 3: View actions
|
# Row 3: View actions
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"),
|
text=_(key="admin_user_view_logs_button"),
|
||||||
callback_data=f"user_action:view_logs:{user_id}"
|
callback_data=f"user_action:view_logs:{user_id}"
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
text=_(key="admin_user_refresh_button"),
|
||||||
callback_data=f"user_action:refresh:{user_id}"
|
callback_data=f"user_action:refresh:{user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Row 4: Back button
|
# Row 4: Quick links
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
text=_(key="user_card_open_profile_button"),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
|
if referrer_id:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="user_card_open_referrer_profile_button"),
|
||||||
|
url=f"tg://user?id={referrer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row 5: Destructive action
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_delete_button"),
|
||||||
|
callback_data=f"user_action:delete_user:{user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row 6: Navigation
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_search_new_button"),
|
||||||
callback_data="admin_action:users_management"
|
callback_data="admin_action:users_management"
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
@@ -106,10 +169,39 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
|||||||
callback_data="admin_action:main"
|
callback_data="admin_action:main"
|
||||||
)
|
)
|
||||||
|
|
||||||
builder.adjust(2, 2, 2, 2)
|
quick_links_width = 2 if referrer_id else 1
|
||||||
|
builder.adjust(2, 2, 2, quick_links_width, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_with_profile_link_fallback(
|
||||||
|
sender: Callable[..., Awaitable[Any]],
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup],
|
||||||
|
user_id: int,
|
||||||
|
parse_mode: Optional[str] = "HTML") -> None:
|
||||||
|
"""Send text with markup and fallback if Telegram rejects tg://user buttons."""
|
||||||
|
send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup}
|
||||||
|
if parse_mode is not None:
|
||||||
|
send_kwargs["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if not is_profile_link_error(exc):
|
||||||
|
raise
|
||||||
|
|
||||||
|
logging.warning(
|
||||||
|
"Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.",
|
||||||
|
user_id,
|
||||||
|
getattr(exc, "message", "") or str(exc),
|
||||||
|
)
|
||||||
|
fallback_markup = remove_profile_link_buttons(markup)
|
||||||
|
send_kwargs["reply_markup"] = fallback_markup
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def format_user_card(user: User, session: AsyncSession,
|
async def format_user_card(user: User, session: AsyncSession,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
i18n_instance, lang: str,
|
i18n_instance, lang: str,
|
||||||
@@ -119,10 +211,10 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
|
|
||||||
# Basic user info
|
# Basic user info
|
||||||
card_parts = []
|
card_parts = []
|
||||||
card_parts.append(f"👤 <b>{_('admin_user_card_title', default='Карточка пользователя')}</b>\n")
|
card_parts.append(f"👤 <b>{_('admin_user_card_title')}</b>\n")
|
||||||
|
|
||||||
# User details
|
# User details
|
||||||
na_value = _("admin_user_na_value", default="N/A")
|
na_value = _("admin_user_na_value")
|
||||||
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
||||||
user_name = safe_first_name or na_value
|
user_name = safe_first_name or na_value
|
||||||
if user.username:
|
if user.username:
|
||||||
@@ -135,23 +227,23 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
username_display = na_value
|
username_display = na_value
|
||||||
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
||||||
|
|
||||||
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
|
||||||
card_parts.append(f"{_('admin_user_name_label', default='👤 <b>Имя:</b>')} {hcode(user_name)}")
|
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
|
||||||
card_parts.append(f"{_('admin_user_username_label', default='📱 <b>Username:</b>')} {hcode(username_display)}")
|
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
|
||||||
card_parts.append(f"{_('admin_user_language_label', default='🌍 <b>Язык:</b>')} {hcode(user.language_code or na_value)}")
|
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
|
||||||
card_parts.append(f"{_('admin_user_registration_label', default='📅 <b>Регистрация:</b>')} {hcode(registration_date)}")
|
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
|
||||||
|
|
||||||
# Ban status
|
# Ban status
|
||||||
ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен")
|
ban_status = _("admin_user_status_banned") if user.is_banned else _("admin_user_status_active")
|
||||||
card_parts.append(f"{_('admin_user_status_label', default='🛡 <b>Статус:</b>')} {ban_status}")
|
card_parts.append(f"{_('admin_user_status_label')} {ban_status}")
|
||||||
|
|
||||||
# Referral info
|
# Referral info
|
||||||
if user.referred_by_id:
|
if user.referred_by_id:
|
||||||
card_parts.append(f"{_('admin_user_referral_label', default='🎁 <b>Привлечен по реферальной программе от:</b>')} {hcode(str(user.referred_by_id))}")
|
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||||
|
|
||||||
# Panel info
|
# Panel info
|
||||||
if user.panel_user_uuid:
|
if user.panel_user_uuid:
|
||||||
card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 <b>Panel UUID:</b>')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
|
card_parts.append(f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
|
||||||
|
|
||||||
card_parts.append("") # Empty line
|
card_parts.append("") # Empty line
|
||||||
|
|
||||||
@@ -159,38 +251,38 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
try:
|
try:
|
||||||
subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id)
|
subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id)
|
||||||
if subscription_details:
|
if subscription_details:
|
||||||
card_parts.append(f"💳 <b>{_('admin_user_subscription_info', default='Информация о подписке:')}</b>")
|
card_parts.append(f"💳 <b>{_('admin_user_subscription_info')}</b>")
|
||||||
|
|
||||||
end_date = subscription_details.get('end_date')
|
end_date = subscription_details.get('end_date')
|
||||||
if end_date:
|
if end_date:
|
||||||
end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date)
|
end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date)
|
||||||
card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ <b>Действует до:</b>')} {hcode(end_date_str)}")
|
card_parts.append(f"{_('admin_user_subscription_active_until')} {hcode(end_date_str)}")
|
||||||
|
|
||||||
status = subscription_details.get('status_from_panel', 'UNKNOWN')
|
status = subscription_details.get('status_from_panel', 'UNKNOWN')
|
||||||
card_parts.append(f"{_('admin_user_panel_status_label', default='📊 <b>Статус на панели:</b>')} {hcode(status)}")
|
card_parts.append(f"{_('admin_user_panel_status_label')} {hcode(status)}")
|
||||||
|
|
||||||
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
||||||
traffic_used = subscription_details.get('traffic_used_bytes')
|
traffic_used = subscription_details.get('traffic_used_bytes')
|
||||||
if traffic_limit and traffic_used is not None:
|
if traffic_limit and traffic_used is not None:
|
||||||
traffic_limit_gb = traffic_limit / (1024**3)
|
traffic_limit_gb = traffic_limit / (1024**3)
|
||||||
traffic_used_gb = traffic_used / (1024**3)
|
traffic_used_gb = traffic_used / (1024**3)
|
||||||
card_parts.append(f"{_('admin_user_traffic_label', default='📊 <b>Трафик:</b>')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
||||||
else:
|
else:
|
||||||
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}")
|
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error getting subscription details for user {user.user_id}: {e}")
|
logging.error(f"Error getting subscription details for user {user.user_id}: {e}")
|
||||||
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}")
|
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_error'))}")
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
try:
|
try:
|
||||||
# Count user logs
|
# Count user logs
|
||||||
logs_count = await message_log_dal.count_user_message_logs(session, user.user_id)
|
logs_count = await message_log_dal.count_user_message_logs(session, user.user_id)
|
||||||
card_parts.append(f"{_('admin_user_actions_count_label', default='📜 <b>Всего действий:</b>')} {hcode(str(logs_count))}")
|
card_parts.append(f"{_('admin_user_actions_count_label')} {hcode(str(logs_count))}")
|
||||||
|
|
||||||
# Check if user had any subscriptions
|
# Check if user had any subscriptions
|
||||||
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
||||||
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
trial_status = _("admin_user_trial_used") if had_subscriptions else _("admin_user_trial_not_used")
|
||||||
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
card_parts.append(f"{_('admin_user_trial_label')} {hcode(trial_status)}")
|
||||||
|
|
||||||
# Financial analytics (admin-only)
|
# Financial analytics (admin-only)
|
||||||
try:
|
try:
|
||||||
@@ -198,11 +290,11 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
|
|
||||||
# Total amount paid by this user
|
# Total amount paid by this user
|
||||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||||
card_parts.append(f"{_('admin_user_total_paid_label', default='💰 <b>Всего оплачено:</b>')} {hcode(f'{total_paid:.2f} RUB')}")
|
card_parts.append(f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}")
|
||||||
|
|
||||||
# Total revenue from referrals
|
# Total revenue from referrals
|
||||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||||
card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 <b>Доход по рефералам:</b>')} {hcode(f'{referral_revenue:.2f} RUB')}")
|
card_parts.append(f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}")
|
||||||
except Exception as e_fin:
|
except Exception as e_fin:
|
||||||
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
|
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
|
||||||
|
|
||||||
@@ -212,8 +304,8 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
stats = await referral_service.get_referral_stats(session, user.user_id)
|
stats = await referral_service.get_referral_stats(session, user.user_id)
|
||||||
invited_count = stats.get('invited_count', 0)
|
invited_count = stats.get('invited_count', 0)
|
||||||
purchased_count = stats.get('purchased_count', 0)
|
purchased_count = stats.get('purchased_count', 0)
|
||||||
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
|
card_parts.append(f"{_('admin_user_invited_friends_label')} {hcode(str(invited_count))}")
|
||||||
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
|
card_parts.append(f"{_('admin_user_ref_purchased_label')} {hcode(str(purchased_count))}")
|
||||||
except Exception as e_rs:
|
except Exception as e_rs:
|
||||||
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
||||||
|
|
||||||
@@ -253,7 +345,6 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
|||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_not_found",
|
"admin_user_not_found",
|
||||||
default="❌ Пользователь не найден: {input}",
|
|
||||||
input=hcode(input_text)
|
input=hcode(input_text)
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -266,18 +357,24 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
|||||||
try:
|
try:
|
||||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_model.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user_model.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user_model.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error displaying user card for {user_model.user_id}: {e}")
|
logging.error(f"Error displaying user card for {user_model.user_id}: {e}")
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_card_error",
|
"admin_user_card_error"
|
||||||
default="❌ Ошибка отображения карточки пользователя"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -307,8 +404,7 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
|||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_not_found_action",
|
"admin_user_not_found_action"
|
||||||
default="Пользователь не найден"
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -324,6 +420,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
|||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||||
elif action == "refresh":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
||||||
|
elif action == "delete_user":
|
||||||
|
await handle_delete_user_prompt(
|
||||||
|
callback, state, user, settings, i18n, current_lang, session
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||||
|
|
||||||
@@ -340,8 +440,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_trial_reset_success",
|
"admin_user_trial_reset_success"
|
||||||
default="✅ Триал сброшен! Пользователь может активировать триал заново."
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
|
|
||||||
# Refresh user card
|
# Refresh user card
|
||||||
@@ -351,8 +450,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
|
|||||||
logging.error(f"Error resetting trial for user {user.user_id}: {e}")
|
logging.error(f"Error resetting trial for user {user.user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_trial_reset_error",
|
"admin_user_trial_reset_error"
|
||||||
default="❌ Ошибка сброса триала"
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -366,7 +464,6 @@ async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: F
|
|||||||
|
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_user_add_subscription_prompt",
|
"admin_user_add_subscription_prompt",
|
||||||
default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:",
|
|
||||||
user_id=user.user_id
|
user_id=user.user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,10 +494,9 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован")
|
status_text = _("admin_user_ban_action_banned") if new_ban_status else _("admin_user_ban_action_unbanned")
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_ban_toggle_success",
|
"admin_user_ban_toggle_success",
|
||||||
default="✅ Пользователь {status}",
|
|
||||||
status=status_text
|
status=status_text
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
|
|
||||||
@@ -417,8 +513,7 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
|
|||||||
logging.error(f"Error toggling ban for user {user.user_id}: {e}")
|
logging.error(f"Error toggling ban for user {user.user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_ban_toggle_error",
|
"admin_user_ban_toggle_error"
|
||||||
default="❌ Ошибка изменения статуса блокировки"
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -432,7 +527,6 @@ async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMCo
|
|||||||
|
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_user_send_message_prompt",
|
"admin_user_send_message_prompt",
|
||||||
default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:",
|
|
||||||
user_id=user.user_id
|
user_id=user.user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -456,13 +550,12 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
|||||||
|
|
||||||
if not logs:
|
if not logs:
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_no_logs",
|
"admin_user_no_logs"
|
||||||
default="📜 У пользователя нет действий"
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
logs_text_parts = [
|
logs_text_parts = [
|
||||||
f"{_('admin_user_recent_actions_title', default='📜 Последние действия пользователя {user_id}:', user_id=user.user_id)}\n"
|
f"{_('admin_user_recent_actions_title', user_id=user.user_id)}\n"
|
||||||
]
|
]
|
||||||
|
|
||||||
for log in logs:
|
for log in logs:
|
||||||
@@ -480,11 +573,11 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
|||||||
# Create inline keyboard for full logs
|
# Create inline keyboard for full logs
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"),
|
text=_(key="admin_user_view_all_logs_button"),
|
||||||
callback_data=f"admin_logs:view_user:{user.user_id}:0"
|
callback_data=f"admin_logs:view_user:{user.user_id}:0"
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"),
|
text=_(key="admin_user_back_to_card_button"),
|
||||||
callback_data=f"user_action:refresh:{user.user_id}"
|
callback_data=f"user_action:refresh:{user.user_id}"
|
||||||
)
|
)
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
@@ -507,8 +600,7 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error viewing logs for user {user.user_id}: {e}")
|
logging.error(f"Error viewing logs for user {user.user_id}: {e}")
|
||||||
await callback.answer(_(
|
await callback.answer(_(
|
||||||
"admin_user_logs_error",
|
"admin_user_logs_error"
|
||||||
default="❌ Ошибка загрузки действий пользователя"
|
|
||||||
), show_alert=True)
|
), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -527,18 +619,28 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
_settings = _Settings()
|
_settings = _Settings()
|
||||||
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
fresh_user.user_id,
|
||||||
|
i18n_instance,
|
||||||
|
lang,
|
||||||
|
fresh_user.referred_by_id
|
||||||
|
)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.edit_text,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await callback.message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -549,8 +651,199 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
await callback.answer("Error refreshing user card", show_alert=True)
|
await callback.answer("Error refreshing user card", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Destructive deletion flow
|
||||||
|
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
|
||||||
|
user: User, settings: Settings, i18n_instance,
|
||||||
|
lang: str, session: AsyncSession):
|
||||||
|
"""Trigger confirmation workflow for destructive deletion."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = callback.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
|
||||||
|
)
|
||||||
|
await callback.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
),
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
target_user_id=user.user_id,
|
||||||
|
delete_initiator_id=admin_id,
|
||||||
|
)
|
||||||
|
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
|
||||||
|
|
||||||
|
prompt_text = _(
|
||||||
|
"admin_user_delete_confirmation_prompt",
|
||||||
|
user_id=hcode(str(user.user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.answer(prompt_text, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
|
||||||
|
)
|
||||||
|
await callback.message.reply(prompt_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_admin_user_deletion(
|
||||||
|
session: AsyncSession,
|
||||||
|
admin_id: int,
|
||||||
|
admin_user: Optional[types.User],
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store audit log for successful deletion."""
|
||||||
|
try:
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": admin_id,
|
||||||
|
"telegram_username": admin_user.username if admin_user else None,
|
||||||
|
"telegram_first_name": admin_user.first_name if admin_user else None,
|
||||||
|
"event_type": "admin:user_deleted",
|
||||||
|
"content": f"Admin {admin_id} deleted user {target_user_id}",
|
||||||
|
"raw_update_preview": None,
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Message handlers for state-based inputs
|
# Message handlers for state-based inputs
|
||||||
|
|
||||||
|
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
|
||||||
|
async def process_delete_user_confirmation_handler(message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Confirm and execute destructive user deletion."""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await message.reply("Language service error.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = message.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete confirmation attempt by user {admin_id}."
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_state_missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
confirmation_input = message.text.strip() if message.text else ""
|
||||||
|
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_cancelled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if confirmation_input != str(target_user_id):
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_mismatch",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
user_model = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
|
if not user_model:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user_model.panel_user_uuid:
|
||||||
|
panel_deleted = await panel_service.delete_user_from_panel(
|
||||||
|
user_model.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not panel_deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_panel_error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = await user_dal.delete_user_and_relations(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_success",
|
||||||
|
user_id=hcode(str(target_user_id)),
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
|
||||||
|
await session.rollback()
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
||||||
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
||||||
settings: Settings, i18n_data: dict,
|
settings: Settings, i18n_data: dict,
|
||||||
@@ -577,8 +870,7 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
|||||||
raise ValueError("Invalid days count")
|
raise ValueError("Invalid days count")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_invalid_days",
|
"admin_user_invalid_days"
|
||||||
default="❌ Неверное количество дней. Введите число от 1 до 3650."
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -592,7 +884,6 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_subscription_added_success",
|
"admin_user_subscription_added_success",
|
||||||
default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
|
||||||
days=days_to_add,
|
days=days_to_add,
|
||||||
user_id=target_user_id
|
user_id=target_user_id
|
||||||
))
|
))
|
||||||
@@ -602,26 +893,31 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
|||||||
if user:
|
if user:
|
||||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
user.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_subscription_added_error",
|
"admin_user_subscription_added_error"
|
||||||
default="❌ Ошибка добавления дней подписки"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error adding subscription days for user {target_user_id}: {e}")
|
logging.error(f"Error adding subscription days for user {target_user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_subscription_added_error",
|
"admin_user_subscription_added_error"
|
||||||
default="❌ Ошибка добавления дней подписки"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -650,8 +946,7 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
text = (message.text or message.caption or "").strip()
|
text = (message.text or message.caption or "").strip()
|
||||||
if len(text) > 4000:
|
if len(text) > 4000:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_message_too_long",
|
"admin_user_message_too_long"
|
||||||
default="❌ Сообщение слишком длинное (максимум 4000 символов)"
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -665,16 +960,14 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
|
|
||||||
# Prepare admin signature and get content
|
# Prepare admin signature and get content
|
||||||
admin_signature = _(
|
admin_signature = _(
|
||||||
"admin_direct_message_signature",
|
"admin_direct_message_signature"
|
||||||
default="\n\n---\n💬 Сообщение от администратора"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
content = get_message_content(message)
|
content = get_message_content(message)
|
||||||
|
|
||||||
if not content.text and not content.file_id:
|
if not content.text and not content.file_id:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_direct_empty_message",
|
"admin_direct_empty_message"
|
||||||
default="❌ Пустое сообщение. Отправьте текст или медиа."
|
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -693,7 +986,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
except TelegramBadRequest as e:
|
except TelegramBadRequest as e:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_broadcast_invalid_html",
|
"admin_broadcast_invalid_html",
|
||||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
|
||||||
error=str(e),
|
error=str(e),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -701,7 +993,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
# Confirm to admin
|
# Confirm to admin
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_message_sent_success",
|
"admin_user_message_sent_success",
|
||||||
default="✅ Сообщение отправлено пользователю {user_id}",
|
|
||||||
user_id=target_user_id
|
user_id=target_user_id
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -711,19 +1002,25 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
subscription_service = SubscriptionService(settings, panel_service)
|
subscription_service = SubscriptionService(settings, panel_service)
|
||||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
target_user.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
target_user.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=target_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error sending direct message to user {target_user_id}: {e}")
|
logging.error(f"Error sending direct message to user {target_user_id}: {e}")
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_message_sent_error",
|
"admin_user_message_sent_error"
|
||||||
default="❌ Ошибка отправки сообщения"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -741,8 +1038,7 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_ban_user_prompt",
|
"admin_ban_user_prompt"
|
||||||
default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -773,8 +1069,7 @@ async def unban_user_prompt_handler(callback: types.CallbackQuery,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
prompt_text = _(
|
prompt_text = _(
|
||||||
"admin_unban_user_prompt",
|
"admin_unban_user_prompt"
|
||||||
default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -810,8 +1105,7 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
if not banned_users:
|
if not banned_users:
|
||||||
message_text = _(
|
message_text = _(
|
||||||
"admin_banned_users_empty",
|
"admin_banned_users_empty"
|
||||||
default="📋 Заблокированные пользователи\n\nСписок пуст"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
user_list = []
|
user_list = []
|
||||||
@@ -823,7 +1117,6 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
message_text = _(
|
message_text = _(
|
||||||
"admin_banned_users_list",
|
"admin_banned_users_list",
|
||||||
default="📋 Заблокированные пользователи ({count}):\n\n{users}",
|
|
||||||
count=len(banned_users),
|
count=len(banned_users),
|
||||||
users="\n".join(user_list)
|
users="\n".join(user_list)
|
||||||
)
|
)
|
||||||
@@ -868,7 +1161,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_not_found",
|
"admin_user_not_found",
|
||||||
default="❌ Пользователь не найден: {input}",
|
|
||||||
input=hcode(input_text)
|
input=hcode(input_text)
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -877,8 +1169,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
# Check if user is already banned
|
# Check if user is already banned
|
||||||
if user_model.is_banned:
|
if user_model.is_banned:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_already_banned",
|
"admin_user_already_banned"
|
||||||
default="⚠️ Пользователь уже заблокирован"
|
|
||||||
))
|
))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
return
|
return
|
||||||
@@ -894,7 +1185,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_ban_success",
|
"admin_user_ban_success",
|
||||||
default="✅ Пользователь {input} заблокирован",
|
|
||||||
input=hcode(input_text)
|
input=hcode(input_text)
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -902,8 +1192,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
logging.error(f"Error banning user {user_model.user_id}: {e}")
|
logging.error(f"Error banning user {user_model.user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_ban_error",
|
"admin_user_ban_error"
|
||||||
default="❌ Ошибка блокировки пользователя"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -939,7 +1228,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_not_found",
|
"admin_user_not_found",
|
||||||
default="❌ Пользователь не найден: {input}",
|
|
||||||
input=hcode(input_text)
|
input=hcode(input_text)
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -948,8 +1236,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
# Check if user is not banned
|
# Check if user is not banned
|
||||||
if not user_model.is_banned:
|
if not user_model.is_banned:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_not_banned",
|
"admin_user_not_banned"
|
||||||
default="⚠️ Пользователь не заблокирован"
|
|
||||||
))
|
))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
return
|
return
|
||||||
@@ -965,7 +1252,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_unban_success",
|
"admin_user_unban_success",
|
||||||
default="✅ Пользователь {input} разблокирован",
|
|
||||||
input=hcode(input_text)
|
input=hcode(input_text)
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -973,8 +1259,71 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
logging.error(f"Error unbanning user {user_model.user_id}: {e}")
|
logging.error(f"Error unbanning user {user_model.user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
"admin_user_unban_error",
|
"admin_user_unban_error"
|
||||||
default="❌ Ошибка разблокировки пользователя"
|
|
||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||||
|
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, bot: Bot,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Display user card when clicked from user list"""
|
||||||
|
try:
|
||||||
|
parts = callback.data.split(":")
|
||||||
|
user_id = int(parts[1])
|
||||||
|
page = int(parts[2])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid user data", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await callback.answer("Language service error", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("User not found", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create keyboard with back to list button
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
keyboard.button(
|
||||||
|
text=_("admin_user_back_to_list_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{page}"
|
||||||
|
)
|
||||||
|
quick_links_width = 2 if user.referred_by_id else 1
|
||||||
|
keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
|
||||||
|
|
||||||
|
# Format user card
|
||||||
|
try:
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
await _send_with_profile_link_fallback(
|
||||||
|
callback.message.edit_text,
|
||||||
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=user.user_id,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user card: {e}")
|
||||||
|
await callback.answer("Error displaying user card", show_alert=True)
|
||||||
|
|||||||
+31
-50
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
|
|||||||
# For all users: referral functionality
|
# For all users: referral functionality
|
||||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||||
referral_result = await create_referral_result(
|
referral_result = await create_referral_result(
|
||||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
inline_query,
|
||||||
|
bot,
|
||||||
|
referral_service,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
settings,
|
||||||
|
session,
|
||||||
)
|
)
|
||||||
if referral_result:
|
if referral_result:
|
||||||
results.append(referral_result)
|
results.append(referral_result)
|
||||||
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
|
|||||||
await inline_query.answer(results=[], cache_time=10)
|
await inline_query.answer(results=[], cache_time=10)
|
||||||
|
|
||||||
|
|
||||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
async def create_referral_result(
|
||||||
|
inline_query: InlineQuery,
|
||||||
|
bot: Bot,
|
||||||
referral_service: ReferralService,
|
referral_service: ReferralService,
|
||||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Optional[InlineQueryResultArticle]:
|
||||||
"""Create referral link result for inline query"""
|
"""Create referral link result for inline query"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
@@ -80,25 +92,27 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = inline_query.from_user.id
|
user_id = inline_query.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
referral_link = await referral_service.generate_referral_link(
|
||||||
|
session, bot_username, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.warning("Could not produce referral link for inline user %s", user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
# Create message content (use same text as friend message)
|
# Create message content (use same text as friend message)
|
||||||
message_text = _(
|
message_text = _(
|
||||||
"referral_friend_message",
|
"referral_friend_message",
|
||||||
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
|
|
||||||
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
|
||||||
referral_link=referral_link
|
referral_link=referral_link
|
||||||
)
|
)
|
||||||
|
|
||||||
return InlineQueryResultArticle(
|
return InlineQueryResultArticle(
|
||||||
id="referral_link",
|
id="referral_link",
|
||||||
title=_(
|
title=_(
|
||||||
"inline_referral_title",
|
"inline_referral_title"
|
||||||
default="🎁 Пригласить друга"
|
|
||||||
),
|
),
|
||||||
description=_(
|
description=_(
|
||||||
"inline_referral_description",
|
"inline_referral_description"
|
||||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
|
||||||
),
|
),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
@@ -149,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
|||||||
|
|
||||||
stats_text = _(
|
stats_text = _(
|
||||||
"inline_user_stats_message",
|
"inline_user_stats_message",
|
||||||
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
|
|
||||||
"📊 Всего: <b>{total}</b>\n"
|
|
||||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
|
||||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
|
||||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
|
||||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
|
||||||
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
|
||||||
total=user_stats['total_users'],
|
total=user_stats['total_users'],
|
||||||
active_today=user_stats['active_today'],
|
active_today=user_stats['active_today'],
|
||||||
paid=user_stats['paid_subscriptions'],
|
paid=user_stats['paid_subscriptions'],
|
||||||
@@ -168,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
|||||||
return InlineQueryResultArticle(
|
return InlineQueryResultArticle(
|
||||||
id="admin_user_stats",
|
id="admin_user_stats",
|
||||||
title=_(
|
title=_(
|
||||||
"inline_admin_user_stats_title",
|
"inline_admin_user_stats_title"
|
||||||
default="📊 Статистика пользователей"
|
|
||||||
),
|
),
|
||||||
description=_(
|
description=_(
|
||||||
"inline_user_stats_description",
|
"inline_user_stats_description",
|
||||||
default="Всего: {total}, Платных: {active}",
|
|
||||||
total=user_stats['total_users'],
|
total=user_stats['total_users'],
|
||||||
active=user_stats['paid_subscriptions']
|
active=user_stats['paid_subscriptions']
|
||||||
),
|
),
|
||||||
@@ -199,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
|||||||
|
|
||||||
stats_text = _(
|
stats_text = _(
|
||||||
"inline_financial_stats_message",
|
"inline_financial_stats_message",
|
||||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
|
||||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
|
||||||
" ({today_count} платежей)\n"
|
|
||||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
|
||||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
|
||||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
|
||||||
today=financial_stats['today_revenue'],
|
today=financial_stats['today_revenue'],
|
||||||
today_count=financial_stats['today_payments_count'],
|
today_count=financial_stats['today_payments_count'],
|
||||||
week=financial_stats['week_revenue'],
|
week=financial_stats['week_revenue'],
|
||||||
@@ -215,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
|||||||
return InlineQueryResultArticle(
|
return InlineQueryResultArticle(
|
||||||
id="admin_financial_stats",
|
id="admin_financial_stats",
|
||||||
title=_(
|
title=_(
|
||||||
"inline_admin_financial_stats_title",
|
"inline_admin_financial_stats_title"
|
||||||
default="💰 Финансовая статистика"
|
|
||||||
),
|
),
|
||||||
description=_(
|
description=_(
|
||||||
"inline_financial_description",
|
"inline_financial_description",
|
||||||
default="Сегодня: {today} RUB",
|
|
||||||
today=f"{financial_stats['today_revenue']:.2f}"
|
today=f"{financial_stats['today_revenue']:.2f}"
|
||||||
),
|
),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
@@ -293,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
|||||||
|
|
||||||
stats_text = _(
|
stats_text = _(
|
||||||
"inline_system_stats_message",
|
"inline_system_stats_message",
|
||||||
default="🖥 <b>Статистика панели</b>\n\n"
|
|
||||||
"🟢 Онлайн: <b>{online}</b>\n"
|
|
||||||
"📊 Активных: <b>{active}</b>\n"
|
|
||||||
"🔴 Отключенных: <b>{disabled}</b>\n"
|
|
||||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
|
||||||
"⚠️ Ограниченные: <b>{limited}</b>\n"
|
|
||||||
"👥 Всего пользователей: <b>{total}</b>\n"
|
|
||||||
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
|
|
||||||
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
|
|
||||||
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
|
|
||||||
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
|
||||||
online=online_now,
|
online=online_now,
|
||||||
active=active_users,
|
active=active_users,
|
||||||
disabled=disabled_users,
|
disabled=disabled_users,
|
||||||
@@ -317,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
|||||||
total_nodes=total_nodes
|
total_nodes=total_nodes
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
stats_text = _("inline_panel_stats_error")
|
||||||
|
|
||||||
return InlineQueryResultArticle(
|
return InlineQueryResultArticle(
|
||||||
id="admin_system_stats",
|
id="admin_system_stats",
|
||||||
title=_(
|
title=_(
|
||||||
"inline_admin_system_stats_title",
|
"inline_admin_system_stats_title"
|
||||||
default="🖥 Системная статистика"
|
|
||||||
),
|
),
|
||||||
description=_(
|
description=_(
|
||||||
"inline_system_description",
|
"inline_system_description",
|
||||||
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
|
|
||||||
online=online_now,
|
online=online_now,
|
||||||
active=active_users
|
active=active_users
|
||||||
),
|
),
|
||||||
@@ -341,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error creating system stats result: {e}")
|
logging.error(f"Error creating system stats result: {e}")
|
||||||
# Fallback error message
|
# Fallback error message
|
||||||
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
error_text = _("inline_panel_stats_error")
|
||||||
|
|
||||||
return InlineQueryResultArticle(
|
return InlineQueryResultArticle(
|
||||||
id="admin_system_stats",
|
id="admin_system_stats",
|
||||||
title=_(
|
title=_(
|
||||||
"inline_admin_system_stats_title",
|
"inline_admin_system_stats_title"
|
||||||
default="🖥 Системная статистика"
|
|
||||||
),
|
),
|
||||||
description=_("inline_system_error", default="Ошибка получения данных"),
|
description=_("inline_system_error"),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
message_text=error_text,
|
message_text=error_text,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
@@ -357,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
|||||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from config.settings import Settings
|
|||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
payment_processing_lock = asyncio.Lock()
|
payment_processing_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
metadata = payment_info_from_webhook.get("metadata", {})
|
metadata = payment_info_from_webhook.get("metadata", {})
|
||||||
user_id_str = metadata.get("user_id")
|
user_id_str = metadata.get("user_id")
|
||||||
subscription_months_str = metadata.get("subscription_months")
|
subscription_months_str = metadata.get("subscription_months")
|
||||||
|
traffic_gb_str = metadata.get("traffic_gb")
|
||||||
|
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
|
||||||
promo_code_id_str = metadata.get("promo_code_id")
|
promo_code_id_str = metadata.get("promo_code_id")
|
||||||
payment_db_id_str = metadata.get("payment_db_id")
|
payment_db_id_str = metadata.get("payment_db_id")
|
||||||
auto_renew_subscription_id_str = metadata.get(
|
auto_renew_subscription_id_str = metadata.get(
|
||||||
@@ -47,8 +50,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
|
|
||||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||||
# we will create/ensure a payment record idempotently using provider payment id.
|
# we will create/ensure a payment record idempotently using provider payment id.
|
||||||
if (not user_id_str or not subscription_months_str
|
if (
|
||||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
not user_id_str
|
||||||
|
or (not subscription_months_str and not traffic_gb_str)
|
||||||
|
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||||
|
):
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||||
)
|
)
|
||||||
@@ -57,15 +63,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
db_user = None
|
db_user = None
|
||||||
try:
|
try:
|
||||||
user_id = int(user_id_str)
|
user_id = int(user_id_str)
|
||||||
subscription_months = int(subscription_months_str)
|
subscription_months = float(subscription_months_str or 0)
|
||||||
|
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||||
payment_db_id = int(
|
payment_db_id = int(
|
||||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
|
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
|
||||||
promo_code_id = int(
|
promo_code_id = int(
|
||||||
promo_code_id_str
|
promo_code_id_str
|
||||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||||
|
|
||||||
amount_data = payment_info_from_webhook.get("amount", {})
|
amount_data = payment_info_from_webhook.get("amount", {})
|
||||||
|
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
||||||
payment_value = float(amount_data.get("value", 0.0))
|
payment_value = float(amount_data.get("value", 0.0))
|
||||||
|
|
||||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||||
@@ -79,9 +87,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=payment_value,
|
amount=payment_value,
|
||||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||||
months=subscription_months,
|
months=months_for_record or 1,
|
||||||
description=payment_info_from_webhook.get(
|
description=payment_info_from_webhook.get(
|
||||||
"description") or f"Auto-renewal for {subscription_months} months",
|
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||||
provider="yookassa",
|
provider="yookassa",
|
||||||
provider_payment_id=yk_payment_id_from_hook,
|
provider_payment_id=yk_payment_id_from_hook,
|
||||||
)
|
)
|
||||||
@@ -139,7 +147,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
# Try to capture and save payment method for future charges if available
|
# Try to capture and save payment method for future charges if available
|
||||||
try:
|
try:
|
||||||
payment_method = payment_info_from_webhook.get("payment_method")
|
payment_method = payment_info_from_webhook.get("payment_method")
|
||||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||||
pm_id = payment_method.get("id")
|
pm_id = payment_method.get("id")
|
||||||
pm_type = payment_method.get("type")
|
pm_type = payment_method.get("type")
|
||||||
title = payment_method.get("title")
|
title = payment_method.get("title")
|
||||||
@@ -170,6 +178,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
card_last4=display_last4,
|
card_last4=display_last4,
|
||||||
card_network=display_network,
|
card_network=display_network,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await user_billing_dal.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=pm_id,
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
set_default=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||||
@@ -184,14 +204,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f"DB Error: Could not update payment record {payment_db_id}")
|
f"DB Error: Could not update payment record {payment_db_id}")
|
||||||
|
|
||||||
|
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
||||||
activation_details = await subscription_service.activate_subscription(
|
activation_details = await subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
subscription_months,
|
months_for_activation,
|
||||||
payment_value,
|
payment_value,
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
promo_code_id_from_payment=promo_code_id,
|
promo_code_id_from_payment=promo_code_id,
|
||||||
provider="yookassa")
|
provider="yookassa",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||||
|
)
|
||||||
|
|
||||||
if not activation_details or not activation_details.get('end_date'):
|
if not activation_details or not activation_details.get('end_date'):
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -205,10 +229,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
applied_promo_bonus_days = activation_details.get(
|
applied_promo_bonus_days = activation_details.get(
|
||||||
"applied_promo_bonus_days", 0)
|
"applied_promo_bonus_days", 0)
|
||||||
|
|
||||||
|
referral_bonus_info = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
subscription_months,
|
months_for_activation or int(subscription_months) or 1,
|
||||||
current_payment_db_id=payment_db_id,
|
current_payment_db_id=payment_db_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
)
|
)
|
||||||
@@ -224,19 +250,37 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||||
|
|
||||||
|
traffic_label = (
|
||||||
|
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||||
|
)
|
||||||
|
config_link_display, connect_button_url = prepare_config_links(
|
||||||
|
settings, activation_details.get("subscription_url") if activation_details else None
|
||||||
|
)
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||||
if is_auto_renew and final_end_date_for_user:
|
if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"yookassa_auto_renewal",
|
"yookassa_auto_renewal",
|
||||||
months=subscription_months,
|
months=int(subscription_months),
|
||||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
)
|
)
|
||||||
details_markup = None
|
details_markup = None
|
||||||
else:
|
elif sale_mode == "traffic":
|
||||||
config_link = activation_details.get("subscription_url") or _(
|
details_message = _(
|
||||||
"config_link_not_available"
|
"payment_successful_traffic_full",
|
||||||
|
traffic_gb=traffic_label,
|
||||||
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—",
|
||||||
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
|
details_markup = get_connect_and_main_keyboard(
|
||||||
|
user_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
preserve_message=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||||
inviter_name_display = _("friend_placeholder")
|
inviter_name_display = _("friend_placeholder")
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
@@ -251,27 +295,27 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
|
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"payment_successful_with_referral_bonus_full",
|
"payment_successful_with_referral_bonus_full",
|
||||||
months=subscription_months,
|
months=int(subscription_months),
|
||||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||||
bonus_days=applied_referee_bonus_days_from_referral,
|
bonus_days=applied_referee_bonus_days_from_referral,
|
||||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
inviter_name=inviter_name_display,
|
inviter_name=inviter_name_display,
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"payment_successful_with_promo_full",
|
"payment_successful_with_promo_full",
|
||||||
months=subscription_months,
|
months=int(subscription_months),
|
||||||
bonus_days=applied_promo_bonus_days,
|
bonus_days=applied_promo_bonus_days,
|
||||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
elif final_end_date_for_user:
|
elif final_end_date_for_user:
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"payment_successful_full",
|
"payment_successful_full",
|
||||||
months=subscription_months,
|
months=int(subscription_months),
|
||||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -280,7 +324,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
details_message = _("payment_successful_error_details")
|
details_message = _("payment_successful_error_details")
|
||||||
|
|
||||||
details_markup = get_connect_and_main_keyboard(
|
details_markup = get_connect_and_main_keyboard(
|
||||||
user_lang, i18n, settings, config_link, preserve_message=True
|
user_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
@@ -303,9 +352,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=payment_value,
|
amount=payment_value,
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||||
months=subscription_months,
|
months=int(subscription_months) if sale_mode != "traffic" else 0,
|
||||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||||
username=user.username if user else None
|
username=user.username if user else None,
|
||||||
|
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send payment notification: {e}")
|
logging.error(f"Failed to send payment notification: {e}")
|
||||||
@@ -487,7 +537,7 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
|
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||||
try:
|
try:
|
||||||
user_id_str = metadata.get("user_id")
|
user_id_str = metadata.get("user_id")
|
||||||
if user_id_str and user_id_str.isdigit():
|
if user_id_str and user_id_str.isdigit():
|
||||||
|
|||||||
@@ -134,16 +134,21 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
new_end_date = result if isinstance(result, datetime) else None
|
new_end_date = result if isinstance(result, datetime) else None
|
||||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||||
config_link = active.get("config_link") if active else None
|
config_link_display = active.get("config_link") if active else None
|
||||||
config_link = config_link or _("config_link_not_available")
|
connect_button_url = active.get("connect_button_url") if active else None
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
|
||||||
response_to_user_text = _(
|
response_to_user_text = _(
|
||||||
"promo_code_applied_success_full",
|
"promo_code_applied_success_full",
|
||||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
reply_markup = get_connect_and_main_keyboard(
|
reply_markup = get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, settings, config_link
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|||||||
@@ -60,12 +60,24 @@ async def referral_command_handler(event: Union[types.Message,
|
|||||||
return
|
return
|
||||||
|
|
||||||
inviter_user_id = event.from_user.id
|
inviter_user_id = event.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(
|
referral_link = await referral_service.generate_referral_link(
|
||||||
bot_username, inviter_user_id)
|
session, bot_username, inviter_user_id)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
await event.answer()
|
||||||
|
return
|
||||||
|
|
||||||
bonus_info_parts = []
|
bonus_info_parts = []
|
||||||
|
if getattr(settings, "traffic_sale_mode", False):
|
||||||
|
bonus_details_str = _("referral_not_available_for_traffic")
|
||||||
|
else:
|
||||||
if settings.subscription_options:
|
if settings.subscription_options:
|
||||||
|
|
||||||
for months_period_key, _price in sorted(
|
for months_period_key, _price in sorted(
|
||||||
settings.subscription_options.items()):
|
settings.subscription_options.items()):
|
||||||
|
|
||||||
@@ -132,7 +144,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
return
|
return
|
||||||
|
|
||||||
inviter_user_id = callback.from_user.id
|
inviter_user_id = callback.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
referral_link = await referral_service.generate_referral_link(
|
||||||
|
session, bot_username, inviter_user_id)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s via inline button.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ async def ensure_required_channel_subscription(
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||||
async def start_command_handler(message: types.Message,
|
async def start_command_handler(message: types.Message,
|
||||||
@@ -328,9 +328,23 @@ async def start_command_handler(message: types.Message,
|
|||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
potential_referrer_id = int(ref_match.group(1))
|
raw_ref_value = ref_match.group(1)
|
||||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
if raw_ref_value.isdigit():
|
||||||
|
if settings.LEGACY_REFS:
|
||||||
|
potential_referrer_id = int(raw_ref_value)
|
||||||
|
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||||
|
session, potential_referrer_id):
|
||||||
referred_by_user_id = potential_referrer_id
|
referred_by_user_id = potential_referrer_id
|
||||||
|
else:
|
||||||
|
normalized_code = raw_ref_value.strip()
|
||||||
|
if normalized_code and normalized_code[0].lower() == "u":
|
||||||
|
normalized_code = normalized_code[1:]
|
||||||
|
ref_user = None
|
||||||
|
if normalized_code:
|
||||||
|
ref_user = await user_dal.get_user_by_referral_code(
|
||||||
|
session, normalized_code)
|
||||||
|
if ref_user and ref_user.user_id != user_id:
|
||||||
|
referred_by_user_id = ref_user.user_id
|
||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
@@ -357,6 +371,17 @@ async def start_command_handler(message: types.Message,
|
|||||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to commit new user {user_id}: {commit_error}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await message.answer(_("error_occurred_processing_request"))
|
||||||
|
return
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||||
)
|
)
|
||||||
@@ -453,21 +478,28 @@ async def start_command_handler(message: types.Message,
|
|||||||
|
|
||||||
# Get updated subscription details
|
# Get updated subscription details
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
config_link = active.get("config_link") if active else None
|
config_link_display = active.get("config_link") if active else None
|
||||||
config_link = config_link or _("config_link_not_available")
|
connect_button_url = active.get("connect_button_url") if active else None
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
|
||||||
new_end_date = result if isinstance(result, datetime) else None
|
new_end_date = result if isinstance(result, datetime) else None
|
||||||
|
|
||||||
promo_success_text = _(
|
promo_success_text = _(
|
||||||
"promo_code_applied_success_full",
|
"promo_code_applied_success_full",
|
||||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
await message.answer(
|
await message.answer(
|
||||||
promo_success_text,
|
promo_success_text,
|
||||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
reply_markup=get_connect_and_main_keyboard(
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, types, Bot
|
from aiogram import Router, F, types, Bot
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -16,12 +17,28 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal, user_billing_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
router = Router(name="user_subscription_core_router")
|
router = Router(name="user_subscription_core_router")
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
|
||||||
|
"""Trim HWID for button text to keep within Telegram limits."""
|
||||||
|
if not hwid:
|
||||||
|
return "-"
|
||||||
|
hwid_str = str(hwid)
|
||||||
|
if len(hwid_str) <= max_length:
|
||||||
|
return hwid_str
|
||||||
|
return f"{hwid_str[:8]}...{hwid_str[-6:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||||
|
"""Stable short token for callback_data; avoids 64b limit with raw HWID."""
|
||||||
|
hwid_str = str(hwid or "")
|
||||||
|
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -40,13 +57,29 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
|||||||
return
|
return
|
||||||
|
|
||||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||||
|
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||||
|
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||||
|
|
||||||
reply_markup = (
|
if traffic_mode:
|
||||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
if traffic_packages:
|
||||||
if settings.subscription_options
|
options = traffic_packages
|
||||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
elif stars_traffic_packages:
|
||||||
|
options = stars_traffic_packages
|
||||||
|
currency_symbol_val = "⭐"
|
||||||
|
else:
|
||||||
|
options = {}
|
||||||
|
else:
|
||||||
|
options = settings.subscription_options
|
||||||
|
|
||||||
|
if options:
|
||||||
|
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||||
|
reply_markup = get_subscription_options_keyboard(
|
||||||
|
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
text_content = get_text("no_subscription_options_available")
|
||||||
|
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
|
||||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
if not target_message_obj:
|
if not target_message_obj:
|
||||||
@@ -104,7 +137,7 @@ async def my_subscription_command_handler(
|
|||||||
text = get_text("subscription_not_active")
|
text = get_text("subscription_not_active")
|
||||||
|
|
||||||
buy_button = InlineKeyboardButton(
|
buy_button = InlineKeyboardButton(
|
||||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
|
||||||
)
|
)
|
||||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
|
||||||
@@ -125,23 +158,48 @@ async def my_subscription_command_handler(
|
|||||||
|
|
||||||
end_date = active.get("end_date")
|
end_date = active.get("end_date")
|
||||||
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||||
tribute_hint = ""
|
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False))
|
||||||
if active.get("status_from_panel", "").lower() == "active":
|
config_link_display = active.get("config_link")
|
||||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
connect_button_url = active.get("connect_button_url")
|
||||||
if local_sub:
|
config_link_value = config_link_display or get_text("config_link_not_available")
|
||||||
if local_sub.provider == "tribute":
|
def _fmt_gb(val: Optional[float]) -> str:
|
||||||
link = None
|
if val is None:
|
||||||
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
return get_text("traffic_na")
|
||||||
tribute_hint = "\n\n" + (
|
try:
|
||||||
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
if isinstance(val, (int, float)):
|
||||||
)
|
val_gb = float(val) / (2**30)
|
||||||
|
return f"{val_gb:.2f} GB"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
if traffic_mode:
|
||||||
|
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
||||||
|
used_display = _fmt_gb(active.get("traffic_used_bytes"))
|
||||||
|
remaining_display = get_text("traffic_na")
|
||||||
|
try:
|
||||||
|
limit_val = active.get("traffic_limit_bytes") or 0
|
||||||
|
used_val = active.get("traffic_used_bytes") or 0
|
||||||
|
remaining_val = max(0, float(limit_val) - float(used_val))
|
||||||
|
remaining_display = _fmt_gb(remaining_val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
text = get_text(
|
||||||
|
"my_traffic_details",
|
||||||
|
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||||
|
end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"),
|
||||||
|
traffic_limit=limit_display,
|
||||||
|
traffic_used=used_display,
|
||||||
|
traffic_left=remaining_display,
|
||||||
|
config_link=config_link_value,
|
||||||
|
)
|
||||||
|
else:
|
||||||
text = get_text(
|
text = get_text(
|
||||||
"my_subscription_details",
|
"my_subscription_details",
|
||||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||||
days_left=max(0, days_left),
|
days_left=max(0, days_left),
|
||||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
config_link=config_link_value,
|
||||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||||
traffic_used=(
|
traffic_used=(
|
||||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||||
@@ -164,7 +222,7 @@ async def my_subscription_command_handler(
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
cfg_link_val = (active or {}).get("config_link")
|
cfg_link_val = connect_button_url or config_link_display
|
||||||
if cfg_link_val:
|
if cfg_link_val:
|
||||||
prepend_rows.append([
|
prepend_rows.append([
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
@@ -224,8 +282,8 @@ async def my_subscription_command_handler(
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
# 2) Auto-renew toggle (if supported and not tribute)
|
# 2) Auto-renew toggle (YooKassa only)
|
||||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
|
||||||
toggle_text = (
|
toggle_text = (
|
||||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||||
)
|
)
|
||||||
@@ -237,7 +295,7 @@ async def my_subscription_command_handler(
|
|||||||
])
|
])
|
||||||
|
|
||||||
# 3) Payment methods management (when autopayments enabled)
|
# 3) Payment methods management (when autopayments enabled)
|
||||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not traffic_mode and settings.yookassa_autopayments_active:
|
||||||
prepend_rows.append([
|
prepend_rows.append([
|
||||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||||
])
|
])
|
||||||
@@ -254,17 +312,17 @@ async def my_subscription_command_handler(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
chat_id=target.chat.id,
|
chat_id=target.chat.id,
|
||||||
text=text + tribute_hint,
|
text=text,
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "main_action:my_devices")
|
@router.callback_query(F.data == "main_action:my_devices")
|
||||||
@@ -321,6 +379,12 @@ async def my_devices_command_handler(
|
|||||||
await target.answer(get_text("no_devices_found"))
|
await target.answer(get_text("no_devices_found"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
devices_list_raw = []
|
||||||
|
if isinstance(devices, dict):
|
||||||
|
devices_list_raw = devices.get("devices") or []
|
||||||
|
elif isinstance(devices, list):
|
||||||
|
devices_list_raw = devices
|
||||||
|
|
||||||
max_devices_value = active.get("max_devices")
|
max_devices_value = active.get("max_devices")
|
||||||
max_devices_display = get_text("devices_unlimited_label")
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
if max_devices_value not in (None, 0):
|
if max_devices_value not in (None, 0):
|
||||||
@@ -331,19 +395,22 @@ async def my_devices_command_handler(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
max_devices_display = str(max_devices_value)
|
max_devices_display = str(max_devices_value)
|
||||||
|
|
||||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
if not devices_list_raw:
|
||||||
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||||
else:
|
else:
|
||||||
devices_list = []
|
devices_list = []
|
||||||
current_devices = len(devices.get('devices') or [])
|
current_devices = len(devices_list_raw)
|
||||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
for index, device in enumerate(devices_list_raw, start=1):
|
||||||
device_model = device.get('deviceModel') or None
|
device_model = device.get('deviceModel') or None
|
||||||
platform = device.get('platform') or None
|
platform = device.get('platform') or None
|
||||||
user_agent = device.get('userAgent') or None
|
user_agent = device.get('userAgent') or None
|
||||||
os_version = device.get('osVersion') or None
|
os_version = device.get('osVersion') or None
|
||||||
created_at = device.get('createdAt')
|
created_at = device.get('createdAt')
|
||||||
hwid = device.get('hwid')
|
hwid = device.get('hwid')
|
||||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
try:
|
||||||
|
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
|
||||||
|
except Exception:
|
||||||
|
created_at_str = str(created_at)
|
||||||
|
|
||||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||||
devices_list.append(device_details)
|
devices_list.append(device_details)
|
||||||
@@ -354,11 +421,14 @@ async def my_devices_command_handler(
|
|||||||
kb = base_markup.inline_keyboard
|
kb = base_markup.inline_keyboard
|
||||||
|
|
||||||
devices_kb = []
|
devices_kb = []
|
||||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
for index, device in enumerate(devices_list_raw, start=1):
|
||||||
hwid = device.get('hwid')
|
hwid = device.get('hwid')
|
||||||
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
if not hwid:
|
||||||
|
continue
|
||||||
|
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
|
||||||
|
hwid_token = _hwid_callback_token(hwid)
|
||||||
|
|
||||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
|
||||||
kb = devices_kb + kb
|
kb = devices_kb + kb
|
||||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||||
|
|
||||||
@@ -397,7 +467,7 @@ async def disconnect_device_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, hwid = callback.data.split(":", 1)
|
_, hwid_token = callback.data.split(":", 1)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
@@ -406,10 +476,32 @@ async def disconnect_device_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||||
if not active:
|
if not active or not active.get("user_id"):
|
||||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
devices = await panel_service.get_user_devices(active.get("user_id"))
|
||||||
|
if not devices:
|
||||||
|
await callback.answer(get_text("no_devices_found"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
devices_list_raw = []
|
||||||
|
if isinstance(devices, dict):
|
||||||
|
devices_list_raw = devices.get("devices") or []
|
||||||
|
elif isinstance(devices, list):
|
||||||
|
devices_list_raw = devices
|
||||||
|
|
||||||
|
hwid = None
|
||||||
|
for device in devices_list_raw:
|
||||||
|
hwid_candidate = device.get("hwid")
|
||||||
|
if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token:
|
||||||
|
hwid = hwid_candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if not hwid:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||||
if not success:
|
if not success:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
@@ -452,8 +544,16 @@ async def toggle_autorenew_handler(
|
|||||||
if not sub or sub.user_id != callback.from_user.id:
|
if not sub or sub.user_id != callback.from_user.id:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
if sub.provider == "tribute":
|
if sub.provider != "yookassa":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show confirmation popup and inline buttons
|
# Show confirmation popup and inline buttons
|
||||||
@@ -502,8 +602,20 @@ async def confirm_autorenew_handler(
|
|||||||
if not sub or sub.user_id != callback.from_user.id:
|
if not sub or sub.user_id != callback.from_user.id:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
if sub.provider == "tribute":
|
if sub.provider != "yookassa":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||||
@@ -529,7 +641,7 @@ async def autorenew_cancel_from_webhook_button(
|
|||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
# Disable auto-renew on the active subscription (non-tribute)
|
# Disable auto-renew on the active subscription
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||||
if not sub:
|
if not sub:
|
||||||
@@ -538,9 +650,9 @@ async def autorenew_cancel_from_webhook_button(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
if sub.provider == "tribute":
|
if sub.provider != "yookassa":
|
||||||
try:
|
try:
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router = Router(name="user_subscription_payment_methods_router")
|
|||||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -51,9 +51,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
|||||||
return get_text("payment_method_wallet_title", last4=l4)
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
return get_text("payment_method_wallet_title", last4="****")
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
if last4:
|
if last4:
|
||||||
network_name = network or get_text("payment_network_card", default="Card")
|
network_name = network or get_text("payment_network_card")
|
||||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
network_name = network or get_text("payment_network_generic")
|
||||||
return get_text("payment_method_generic_title", network=network_name)
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
for m in methods:
|
for m in methods:
|
||||||
@@ -75,7 +75,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
|||||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -109,7 +109,7 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
|||||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -130,7 +130,7 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
|||||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -177,9 +177,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
|||||||
return _("payment_method_wallet_title", last4=l4)
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
return _("payment_method_wallet_title", last4="****")
|
return _("payment_method_wallet_title", last4="****")
|
||||||
if last4:
|
if last4:
|
||||||
network_name = network or _("payment_network_card", default="Card")
|
network_name = network or _("payment_network_card")
|
||||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
network_name = network or _("payment_network_generic", default="Payment method")
|
network_name = network or _("payment_network_generic")
|
||||||
return _("payment_method_generic_title", network=network_name)
|
return _("payment_method_generic_title", network=network_name)
|
||||||
title = _format_pm_title(m.card_network, m.card_last4)
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
@@ -204,7 +204,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
|||||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -239,9 +239,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
|||||||
return _("payment_method_wallet_title", last4=l4)
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
return _("payment_method_wallet_title", last4="****")
|
return _("payment_method_wallet_title", last4="****")
|
||||||
if last4:
|
if last4:
|
||||||
network_name = network or _("payment_network_card", default="Card")
|
network_name = network or _("payment_network_card")
|
||||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
network_name = network or _("payment_network_generic", default="Payment method")
|
network_name = network or _("payment_network_generic")
|
||||||
return _("payment_method_generic_title", network=network_name)
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||||
@@ -307,9 +307,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
|||||||
return _("payment_method_wallet_title", last4=l4)
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
return _("payment_method_wallet_title", last4="****")
|
return _("payment_method_wallet_title", last4="****")
|
||||||
if last4:
|
if last4:
|
||||||
network_name = network or _("payment_network_card", default="Card")
|
network_name = network or _("payment_network_card")
|
||||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
network_name = network or _("payment_network_generic", default="Payment method")
|
network_name = network or _("payment_network_generic")
|
||||||
return _("payment_method_generic_title", network=network_name)
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||||
@@ -325,7 +325,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
|||||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not settings.yookassa_autopayments_active:
|
||||||
try:
|
try:
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
@@ -390,7 +390,14 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
|||||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||||
|
|
||||||
def _format_item(p: Payment) -> str:
|
def _format_item(p: Payment) -> str:
|
||||||
|
if traffic_mode:
|
||||||
|
units_val = p.subscription_duration_months or 0
|
||||||
|
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||||
|
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
||||||
|
else:
|
||||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||||
@@ -433,9 +440,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
|||||||
return get_text("payment_method_wallet_title", last4=l4)
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
return get_text("payment_method_wallet_title", last4="****")
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
if last4:
|
if last4:
|
||||||
network_name = network or get_text("payment_network_card", default="Card")
|
network_name = network or get_text("payment_network_card")
|
||||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
network_name = network or get_text("payment_network_generic")
|
||||||
return get_text("payment_method_generic_title", network=network_name)
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
title = _format_pm_title(m.card_network, m.card_last4)
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
@@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.crypto_pay_service import CryptoPayService
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_crypto_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||||
|
async def pay_crypto_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
cryptopay_service: CryptoPayService,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
months = float(parts[0])
|
||||||
|
price_amount = float(parts[1])
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_url = await cryptopay_service.create_invoice(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
amount=price_amount,
|
||||||
|
description=payment_description,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if invoice_url:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
invoice_url,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
invoice_url,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import payment_dal
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_freekassa_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||||
|
async def pay_fk_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
freekassa_service: FreeKassaService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not freekassa_service or not freekassa_service.configured:
|
||||||
|
logging.error("FreeKassa service is not configured or unavailable.")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
months = float(parts[0])
|
||||||
|
price_rub = float(parts[1])
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
|
||||||
|
payment_record_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": price_rub,
|
||||||
|
"currency": currency_code,
|
||||||
|
"status": "pending_freekassa",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": int(months),
|
||||||
|
"provider": "freekassa",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_create:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
success, response_data = await freekassa_service.create_order(
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
user_id=payment_record.user_id,
|
||||||
|
months=months,
|
||||||
|
amount=price_rub,
|
||||||
|
currency=freekassa_service.default_currency,
|
||||||
|
payment_method_id=freekassa_service.payment_method_id,
|
||||||
|
ip_address=freekassa_service.server_ip,
|
||||||
|
extra_params={
|
||||||
|
"us_method": freekassa_service.payment_method_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
location = response_data.get("location")
|
||||||
|
order_hash = response_data.get("orderHash")
|
||||||
|
order_id_api = response_data.get("orderId")
|
||||||
|
provider_identifier = order_hash or order_id_api
|
||||||
|
|
||||||
|
if provider_identifier:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
str(provider_identifier),
|
||||||
|
payment_record.status,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if location:
|
||||||
|
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
|
||||||
|
order_info_text = get_text(
|
||||||
|
"free_kassa_order_info",
|
||||||
|
order_id=order_identifier_display,
|
||||||
|
date=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"{order_info_text}\n\n" + get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
location,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
f"{order_info_text}\n\n" + get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
location,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa: create_order failed for payment %s with response %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
"failed_creation",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.platega_service import PlategaService
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import payment_dal
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_platega_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_platega:"))
|
||||||
|
async def pay_platega_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
platega_service: PlategaService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not platega_service or not platega_service.configured:
|
||||||
|
logging.error("Platega service is not configured or unavailable.")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
months = float(parts[0])
|
||||||
|
price_rub = float(parts[1])
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
|
||||||
|
payment_record_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": price_rub,
|
||||||
|
"currency": currency_code,
|
||||||
|
"status": "pending_platega",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": int(months),
|
||||||
|
"provider": "platega",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_create:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
payload_meta = json.dumps(
|
||||||
|
{
|
||||||
|
"payment_db_id": payment_record.payment_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"months": months,
|
||||||
|
"sale_mode": sale_mode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
success, response_data = await platega_service.create_transaction(
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
amount=price_rub,
|
||||||
|
currency=currency_code,
|
||||||
|
description=payment_description,
|
||||||
|
payload=payload_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||||
|
redirect_url = (
|
||||||
|
response_data.get("redirect")
|
||||||
|
or response_data.get("url")
|
||||||
|
or response_data.get("paymentUrl")
|
||||||
|
)
|
||||||
|
provider_status = response_data.get("status", payment_record.status)
|
||||||
|
|
||||||
|
if transaction_id and redirect_url:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
str(transaction_id),
|
||||||
|
str(provider_status),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
redirect_url,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
redirect_url,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.error(
|
||||||
|
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
"failed_creation",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.severpay_service import SeverPayService
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import payment_dal
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_severpay_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||||
|
async def pay_severpay_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
severpay_service: SeverPayService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not severpay_service or not severpay_service.configured:
|
||||||
|
logging.error("SeverPay service is not configured or unavailable.")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
months = float(parts[0])
|
||||||
|
price_rub = float(parts[1])
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
|
||||||
|
payment_record_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": price_rub,
|
||||||
|
"currency": currency_code,
|
||||||
|
"status": "pending_severpay",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": int(months),
|
||||||
|
"provider": "severpay",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_create:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
success, response_data = await severpay_service.create_payment(
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
amount=price_rub,
|
||||||
|
currency=currency_code,
|
||||||
|
description=payment_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
payment_link = (
|
||||||
|
response_data.get("url")
|
||||||
|
or response_data.get("payment_url")
|
||||||
|
or response_data.get("paymentUrl")
|
||||||
|
)
|
||||||
|
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||||
|
|
||||||
|
if provider_identifier:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
str(provider_identifier),
|
||||||
|
payment_record.status,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment_link:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
payment_link,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
payment_link,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=f"subscribe_period:{human_value}",
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.error(
|
||||||
|
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
"failed_creation",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.stars_service import StarsService
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_stars_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||||
|
async def pay_stars_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
stars_service: StarsService,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings.STARS_ENABLED:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
months = float(parts[0])
|
||||||
|
stars_price = int(float(parts[1]))
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_db_id = await stars_service.create_invoice(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
stars_price=stars_price,
|
||||||
|
description=payment_description,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment_db_id:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(
|
||||||
|
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=human_value,
|
||||||
|
),
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text=get_text("back_to_payment_methods_button"),
|
||||||
|
callback_data=f"subscribe_period:{human_value}",
|
||||||
|
)]
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.pre_checkout_query()
|
||||||
|
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||||
|
try:
|
||||||
|
await query.answer(ok=True)
|
||||||
|
except Exception:
|
||||||
|
# Nothing else to do here; Telegram will show an error if not answered
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.successful_payment)
|
||||||
|
async def handle_successful_stars_payment(
|
||||||
|
message: types.Message,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
stars_service: StarsService,
|
||||||
|
):
|
||||||
|
payload = (message.successful_payment.invoice_payload
|
||||||
|
if message and message.successful_payment else "")
|
||||||
|
try:
|
||||||
|
parts = (payload or "").split(":")
|
||||||
|
payment_db_id = int(parts[0])
|
||||||
|
months = float(parts[1]) if len(parts) > 1 else 0
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||||
|
await stars_service.process_successful_payment(
|
||||||
|
session=session,
|
||||||
|
message=message,
|
||||||
|
payment_db_id=payment_db_id,
|
||||||
|
months=months,
|
||||||
|
stars_amount=stars_amount,
|
||||||
|
i18n_data=i18n_data,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_selection_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||||
|
async def select_subscription_period_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||||
|
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||||
|
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||||
|
try:
|
||||||
|
months = float(callback.data.split(":")[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
price_source = traffic_packages if traffic_mode else settings.subscription_options
|
||||||
|
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
|
||||||
|
|
||||||
|
price_rub = price_source.get(months)
|
||||||
|
stars_price = stars_price_source.get(months)
|
||||||
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
|
|
||||||
|
if price_rub is None:
|
||||||
|
if traffic_mode and not price_source and stars_price is not None:
|
||||||
|
currency_methods_enabled = any(
|
||||||
|
[
|
||||||
|
settings.FREEKASSA_ENABLED,
|
||||||
|
settings.PLATEGA_ENABLED,
|
||||||
|
settings.SEVERPAY_ENABLED,
|
||||||
|
settings.YOOKASSA_ENABLED,
|
||||||
|
settings.CRYPTOPAY_ENABLED,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if currency_methods_enabled:
|
||||||
|
logging.error(
|
||||||
|
"Currency price missing for traffic option %s while fiat providers are enabled.",
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
price_rub = 0.0
|
||||||
|
currency_symbol_val = "⭐"
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
|
||||||
|
reply_markup = get_payment_method_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
stars_price,
|
||||||
|
currency_symbol_val,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
sale_mode="traffic" if traffic_mode else "subscription",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(
|
||||||
|
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||||
|
)
|
||||||
|
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_back_to_main_menu_markup,
|
||||||
|
get_payment_url_keyboard,
|
||||||
|
get_yk_autopay_choice_keyboard,
|
||||||
|
get_yk_saved_cards_keyboard,
|
||||||
|
)
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.yookassa_service import YooKassaService
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import payment_dal, user_billing_dal
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_yookassa_router")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(val: float) -> str:
|
||||||
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||||
|
try:
|
||||||
|
parts = payload.split(":")
|
||||||
|
value = float(parts[0])
|
||||||
|
price = float(parts[1])
|
||||||
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||||
|
return value, price, sale_mode
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
|
||||||
|
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
||||||
|
s = (name or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||||
|
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||||
|
elif last4:
|
||||||
|
network_name = network or get_text("payment_network_card")
|
||||||
|
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
else:
|
||||||
|
network_name = network or get_text("payment_network_generic")
|
||||||
|
title = get_text("payment_method_generic_title", network=network_name)
|
||||||
|
return f"⭐ {title}" if is_default else title
|
||||||
|
|
||||||
|
|
||||||
|
async def _initiate_yk_payment(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
yookassa_service: YooKassaService,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
current_lang: str,
|
||||||
|
get_text,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
price_rub: float,
|
||||||
|
currency_code_for_yk: str,
|
||||||
|
save_payment_method: bool,
|
||||||
|
back_callback: str,
|
||||||
|
payment_method_id: Optional[str] = None,
|
||||||
|
selected_method_internal_id: Optional[int] = None,
|
||||||
|
sale_mode: str = "subscription",
|
||||||
|
) -> bool:
|
||||||
|
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||||
|
if not callback.message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payment_description = (
|
||||||
|
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else get_text("payment_description_subscription", months=int(months))
|
||||||
|
)
|
||||||
|
payment_record_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": price_rub,
|
||||||
|
"currency": currency_code_for_yk,
|
||||||
|
"status": "pending_yookassa",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": int(months),
|
||||||
|
}
|
||||||
|
|
||||||
|
db_payment_record = None
|
||||||
|
try:
|
||||||
|
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||||
|
await session.commit()
|
||||||
|
logging.info(
|
||||||
|
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||||
|
)
|
||||||
|
except Exception as e_db_payment:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not db_payment_record:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
yookassa_metadata = {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"subscription_months": str(months),
|
||||||
|
"payment_db_id": str(db_payment_record.payment_id),
|
||||||
|
"sale_mode": sale_mode,
|
||||||
|
}
|
||||||
|
if sale_mode == "traffic":
|
||||||
|
yookassa_metadata["traffic_gb"] = str(months)
|
||||||
|
if payment_method_id:
|
||||||
|
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||||
|
|
||||||
|
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||||
|
|
||||||
|
payment_response_yk = await yookassa_service.create_payment(
|
||||||
|
amount=price_rub,
|
||||||
|
currency=currency_code_for_yk,
|
||||||
|
description=payment_description,
|
||||||
|
metadata=yookassa_metadata,
|
||||||
|
receipt_email=receipt_email_for_yk,
|
||||||
|
save_payment_method=save_payment_method,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||||
|
pm = payment_response_yk.get("payment_method")
|
||||||
|
try:
|
||||||
|
if pm and pm.get("id"):
|
||||||
|
pm_type = pm.get("type")
|
||||||
|
title = pm.get("title")
|
||||||
|
card = pm.get("card") or {}
|
||||||
|
account_number = pm.get("account_number") or pm.get("account")
|
||||||
|
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||||
|
display_network = card.get("card_type") or title or "Card"
|
||||||
|
display_last4 = card.get("last4")
|
||||||
|
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||||
|
display_network = "YooMoney"
|
||||||
|
display_last4 = (
|
||||||
|
account_number[-4:]
|
||||||
|
if isinstance(account_number, str) and len(account_number) >= 4
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||||
|
display_last4 = None
|
||||||
|
await user_billing_dal.upsert_yk_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
payment_method_id=pm["id"],
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await user_billing_dal.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=pm["id"],
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
set_default=save_payment_method,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_db_id=db_payment_record.payment_id,
|
||||||
|
new_status=payment_response_yk.get("status", "pending"),
|
||||||
|
yk_payment_id=payment_response_yk.get("id"),
|
||||||
|
)
|
||||||
|
if selected_method_internal_id is not None:
|
||||||
|
try:
|
||||||
|
await user_billing_dal.set_user_default_payment_method(
|
||||||
|
session, user_id, selected_method_internal_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to set default payment method after initiating payment")
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_update_ykid:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=_format_value(months),
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
payment_response_yk["confirmation_url"],
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=back_callback,
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(
|
||||||
|
f"Edit message for payment link failed: {e_edit}. Sending new one."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text(
|
||||||
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||||
|
months=int(months),
|
||||||
|
traffic_gb=_format_value(months),
|
||||||
|
),
|
||||||
|
reply_markup=get_payment_url_keyboard(
|
||||||
|
payment_response_yk["confirmation_url"],
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
back_callback=back_callback,
|
||||||
|
back_text_key="back_to_payment_methods_button",
|
||||||
|
),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
if payment_response_yk and payment_method_id:
|
||||||
|
status_to_store = payment_response_yk.get("status", "pending")
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_db_id=db_payment_record.payment_id,
|
||||||
|
new_status=status_to_store,
|
||||||
|
yk_payment_id=payment_response_yk.get("id"),
|
||||||
|
)
|
||||||
|
if selected_method_internal_id is not None:
|
||||||
|
try:
|
||||||
|
await user_billing_dal.set_user_default_payment_method(
|
||||||
|
session, user_id, selected_method_internal_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to set default payment method after saved-card payment start")
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_update_saved:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message_text,
|
||||||
|
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
message_text,
|
||||||
|
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session, db_payment_record.payment_id, "failed_creation"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_fail_create:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||||
|
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not yookassa_service or not yookassa_service.configured:
|
||||||
|
logging.error("YooKassa service is not configured or unavailable.")
|
||||||
|
target_msg_edit = callback.message
|
||||||
|
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
except ValueError:
|
||||||
|
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = _parse_offer_payload(data_payload)
|
||||||
|
if not parsed:
|
||||||
|
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
months, price_rub, sale_mode = parsed
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
currency_code_for_yk = "RUB"
|
||||||
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||||
|
autopay_require_binding = bool(
|
||||||
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||||
|
)
|
||||||
|
saved_methods: List = []
|
||||||
|
if autopay_enabled:
|
||||||
|
try:
|
||||||
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||||
|
session, user_id, provider="yookassa"
|
||||||
|
)
|
||||||
|
except Exception as e_list:
|
||||||
|
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||||
|
saved_methods = []
|
||||||
|
|
||||||
|
if autopay_enabled and saved_methods:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text("yookassa_autopay_flow_prompt"),
|
||||||
|
reply_markup=get_yk_autopay_choice_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
has_saved_cards=True,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text("yookassa_autopay_flow_prompt"),
|
||||||
|
reply_markup=get_yk_autopay_choice_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
has_saved_cards=True,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
await _initiate_yk_payment(
|
||||||
|
callback,
|
||||||
|
settings=settings,
|
||||||
|
session=session,
|
||||||
|
yookassa_service=yookassa_service,
|
||||||
|
i18n=i18n,
|
||||||
|
current_lang=current_lang,
|
||||||
|
get_text=get_text,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
price_rub=price_rub,
|
||||||
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||||
|
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
||||||
|
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not yookassa_service or not yookassa_service.configured:
|
||||||
|
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
except ValueError:
|
||||||
|
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = _parse_offer_payload(data_payload)
|
||||||
|
if not parsed:
|
||||||
|
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
months, price_rub, sale_mode = parsed
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
currency_code_for_yk = "RUB"
|
||||||
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||||
|
autopay_require_binding = bool(
|
||||||
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||||
|
)
|
||||||
|
|
||||||
|
await _initiate_yk_payment(
|
||||||
|
callback,
|
||||||
|
settings=settings,
|
||||||
|
session=session,
|
||||||
|
yookassa_service=yookassa_service,
|
||||||
|
i18n=i18n,
|
||||||
|
current_lang=current_lang,
|
||||||
|
get_text=get_text,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
price_rub=price_rub,
|
||||||
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||||
|
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
||||||
|
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
except ValueError:
|
||||||
|
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
if len(parts) < 2:
|
||||||
|
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
months = float(parts[0])
|
||||||
|
price_rub = float(parts[1])
|
||||||
|
page = int(parts[2]) if len(parts) > 2 else 0
|
||||||
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||||
|
if not autopay_enabled:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
try:
|
||||||
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||||
|
session, user_id, provider="yookassa"
|
||||||
|
)
|
||||||
|
except Exception as e_list:
|
||||||
|
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||||
|
saved_methods = []
|
||||||
|
|
||||||
|
if not saved_methods:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text("yookassa_autopay_no_saved_cards"),
|
||||||
|
reply_markup=get_yk_autopay_choice_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
has_saved_cards=False,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text("yookassa_autopay_no_saved_cards"),
|
||||||
|
reply_markup=get_yk_autopay_choice_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
has_saved_cards=False,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
cards: List[Tuple[str, str]] = []
|
||||||
|
for method in saved_methods:
|
||||||
|
title = _format_saved_payment_method_title(
|
||||||
|
get_text, method.card_network, method.card_last4, method.is_default
|
||||||
|
)
|
||||||
|
cards.append((str(method.method_id), title))
|
||||||
|
|
||||||
|
per_page = 5
|
||||||
|
max_page = max(0, (len(cards) - 1) // per_page)
|
||||||
|
page = max(0, min(page, max_page))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text("yookassa_autopay_choose_saved_card"),
|
||||||
|
reply_markup=get_yk_saved_cards_keyboard(
|
||||||
|
cards,
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
page=page,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text("yookassa_autopay_choose_saved_card"),
|
||||||
|
reply_markup=get_yk_saved_cards_keyboard(
|
||||||
|
cards,
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
page=page,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
||||||
|
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not yookassa_service or not yookassa_service.configured:
|
||||||
|
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
except ValueError:
|
||||||
|
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = data_payload.split(":")
|
||||||
|
if len(parts) < 3:
|
||||||
|
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
months = float(parts[0])
|
||||||
|
price_rub = float(parts[1])
|
||||||
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||||
|
if not autopay_enabled:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
method_identifier = parts[2]
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||||
|
session, user_id, provider="yookassa"
|
||||||
|
)
|
||||||
|
except Exception as e_list:
|
||||||
|
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||||
|
saved_methods = []
|
||||||
|
|
||||||
|
selected_method = None
|
||||||
|
for method in saved_methods:
|
||||||
|
if method_identifier.isdigit():
|
||||||
|
if method.method_id == int(method_identifier):
|
||||||
|
selected_method = method
|
||||||
|
break
|
||||||
|
if method.provider_payment_method_id == method_identifier:
|
||||||
|
selected_method = method
|
||||||
|
break
|
||||||
|
|
||||||
|
if not selected_method:
|
||||||
|
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
currency_code_for_yk = "RUB"
|
||||||
|
|
||||||
|
await _initiate_yk_payment(
|
||||||
|
callback,
|
||||||
|
settings=settings,
|
||||||
|
session=session,
|
||||||
|
yookassa_service=yookassa_service,
|
||||||
|
i18n=i18n,
|
||||||
|
current_lang=current_lang,
|
||||||
|
get_text=get_text,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
price_rub=price_rub,
|
||||||
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
|
save_payment_method=False,
|
||||||
|
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||||
|
payment_method_id=selected_method.provider_payment_method_id,
|
||||||
|
selected_method_internal_id=selected_method.method_id,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_main_menu_inline_keyboard,
|
get_main_menu_inline_keyboard,
|
||||||
get_connect_and_main_keyboard,
|
get_connect_and_main_keyboard,
|
||||||
)
|
)
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from .start import send_main_menu
|
from .start import send_main_menu
|
||||||
|
|
||||||
@@ -76,7 +77,9 @@ async def request_trial_confirmation_handler(
|
|||||||
|
|
||||||
final_message_text_in_chat = ""
|
final_message_text_in_chat = ""
|
||||||
show_trial_button_after_action = False
|
show_trial_button_after_action = False
|
||||||
|
config_link_display_for_trial = None
|
||||||
config_link_for_trial = None
|
config_link_for_trial = None
|
||||||
|
connect_button_url_for_trial = None
|
||||||
|
|
||||||
if activation_result and activation_result.get("activated"):
|
if activation_result and activation_result.get("activated"):
|
||||||
try:
|
try:
|
||||||
@@ -85,9 +88,10 @@ async def request_trial_confirmation_handler(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
end_date_obj = activation_result.get("end_date")
|
end_date_obj = activation_result.get("end_date")
|
||||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
|
||||||
"config_link_not_available"
|
settings, activation_result.get("subscription_url")
|
||||||
)
|
)
|
||||||
|
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||||
|
|
||||||
traffic_gb_val = activation_result.get(
|
traffic_gb_val = activation_result.get(
|
||||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||||
@@ -142,7 +146,11 @@ async def request_trial_confirmation_handler(
|
|||||||
|
|
||||||
reply_markup = (
|
reply_markup = (
|
||||||
get_connect_and_main_keyboard(
|
get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, settings, config_link_for_trial
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display_for_trial,
|
||||||
|
connect_button_url=connect_button_url_for_trial,
|
||||||
)
|
)
|
||||||
if activation_result and activation_result.get("activated")
|
if activation_result and activation_result.get("activated")
|
||||||
else get_main_menu_inline_keyboard(
|
else get_main_menu_inline_keyboard(
|
||||||
@@ -221,7 +229,9 @@ async def confirm_activate_trial_handler(
|
|||||||
|
|
||||||
final_message_text_in_chat = ""
|
final_message_text_in_chat = ""
|
||||||
show_trial_button_after_action = False
|
show_trial_button_after_action = False
|
||||||
|
config_link_display_for_trial = None
|
||||||
config_link_for_trial = None
|
config_link_for_trial = None
|
||||||
|
connect_button_url_for_trial = None
|
||||||
|
|
||||||
if activation_result and activation_result.get("activated"):
|
if activation_result and activation_result.get("activated"):
|
||||||
try:
|
try:
|
||||||
@@ -230,9 +240,10 @@ async def confirm_activate_trial_handler(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
end_date_obj = activation_result.get("end_date")
|
end_date_obj = activation_result.get("end_date")
|
||||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
|
||||||
"config_link_not_available"
|
settings, activation_result.get("subscription_url")
|
||||||
)
|
)
|
||||||
|
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||||
|
|
||||||
traffic_gb_val = activation_result.get(
|
traffic_gb_val = activation_result.get(
|
||||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||||
@@ -275,7 +286,11 @@ async def confirm_activate_trial_handler(
|
|||||||
|
|
||||||
reply_markup = (
|
reply_markup = (
|
||||||
get_connect_and_main_keyboard(
|
get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, settings, config_link_for_trial
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link_display_for_trial,
|
||||||
|
connect_button_url=connect_button_url_for_trial,
|
||||||
)
|
)
|
||||||
if activation_result and activation_result.get("activated")
|
if activation_result and activation_result.get("activated")
|
||||||
else get_main_menu_inline_keyboard(
|
else get_main_menu_inline_keyboard(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
|||||||
callback_data="admin_section:promo_marketing")
|
callback_data="admin_section:promo_marketing")
|
||||||
|
|
||||||
# Реклама
|
# Реклама
|
||||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
builder.button(text=_(key="admin_ads_section"),
|
||||||
callback_data="admin_action:ads")
|
callback_data="admin_action:ads")
|
||||||
|
|
||||||
# Системные функции
|
# Системные функции
|
||||||
@@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
|||||||
|
|
||||||
builder.button(text=_(key="admin_stats_button"),
|
builder.button(text=_(key="admin_stats_button"),
|
||||||
callback_data="admin_action:stats")
|
callback_data="admin_action:stats")
|
||||||
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
|
builder.button(text=_(key="admin_view_payments_button"),
|
||||||
callback_data="admin_action:view_payments")
|
callback_data="admin_action:view_payments")
|
||||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||||
callback_data="admin_action:view_logs_menu")
|
callback_data="admin_action:view_logs_menu")
|
||||||
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
builder.button(text=_(key="admin_users_management_button"),
|
builder.button(text=_(key="admin_users_management_button"),
|
||||||
callback_data="admin_action:users_management")
|
callback_data="admin_action:users_list:0")
|
||||||
|
builder.button(text=_(key="admin_users_search_button"),
|
||||||
|
callback_data="admin_action:users_search_prompt")
|
||||||
builder.button(text=_(key="admin_ban_management_section"),
|
builder.button(text=_(key="admin_ban_management_section"),
|
||||||
callback_data="admin_section:ban_management")
|
callback_data="admin_section:ban_management")
|
||||||
|
|
||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
builder.adjust(2, 1)
|
builder.adjust(2, 1, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
|||||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
builder.button(text=_(key="admin_ads_create_button"),
|
||||||
callback_data="admin_action:ads_create")
|
callback_data="admin_action:ads_create")
|
||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
@@ -154,7 +156,7 @@ def get_ads_list_keyboard(
|
|||||||
if current_page > 0:
|
if current_page > 0:
|
||||||
row.append(
|
row.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
text="⬅️ " + _("prev_page_button"),
|
||||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -167,14 +169,14 @@ def get_ads_list_keyboard(
|
|||||||
if current_page < total_pages - 1:
|
if current_page < total_pages - 1:
|
||||||
row.append(
|
row.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("next_page_button", default="Next") + " ➡️",
|
text=_("next_page_button") + " ➡️",
|
||||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
builder.row(*row)
|
builder.row(*row)
|
||||||
|
|
||||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
builder.button(text=_(key="admin_ads_create_button"),
|
||||||
callback_data="admin_action:ads_create")
|
callback_data="admin_action:ads_create")
|
||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
@@ -186,9 +188,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page:
|
|||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
# Dangerous action: Delete campaign
|
# Dangerous action: Delete campaign
|
||||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
builder.button(text=_(key="admin_ads_delete_button"),
|
||||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
builder.button(text=_(key="back_to_ads_list_button"),
|
||||||
callback_data=f"admin_ads:page:{back_page}")
|
callback_data=f"admin_ads:page:{back_page}")
|
||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
@@ -225,12 +227,12 @@ def get_logs_pagination_keyboard(
|
|||||||
if current_page > 0:
|
if current_page > 0:
|
||||||
row_buttons.append(
|
row_buttons.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
text="⬅️ " + _("prev_page_button"),
|
||||||
callback_data=f"{base_callback_data}:{current_page - 1}"))
|
callback_data=f"{base_callback_data}:{current_page - 1}"))
|
||||||
if current_page < total_pages - 1:
|
if current_page < total_pages - 1:
|
||||||
row_buttons.append(
|
row_buttons.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("next_page_button", default="Next") + " ➡️",
|
text=_("next_page_button") + " ➡️",
|
||||||
callback_data=f"{base_callback_data}:{current_page + 1}"))
|
callback_data=f"{base_callback_data}:{current_page + 1}"))
|
||||||
|
|
||||||
if row_buttons: builder.row(*row_buttons)
|
if row_buttons: builder.row(*row_buttons)
|
||||||
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||||
|
total_users: int, i18n_instance, lang: str,
|
||||||
|
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||||
|
"""Generate keyboard for paginated user list"""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Add user buttons
|
||||||
|
for user in users:
|
||||||
|
user_display_parts = []
|
||||||
|
if user.username:
|
||||||
|
user_display_parts.append(f"@{user.username}")
|
||||||
|
user_display_parts.append(f"ID: {user.user_id}")
|
||||||
|
if user.first_name:
|
||||||
|
user_display_parts.append(f"- {user.first_name}")
|
||||||
|
|
||||||
|
button_text = " ".join(user_display_parts)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pagination buttons
|
||||||
|
if total_users > page_size:
|
||||||
|
total_pages = math.ceil(total_users / page_size)
|
||||||
|
pagination_buttons = []
|
||||||
|
if current_page > 0:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("prev_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{current_page + 1}/{total_pages}",
|
||||||
|
callback_data="stub_page_display"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_page < total_pages - 1:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("next_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pagination_buttons:
|
||||||
|
builder.row(*pagination_buttons)
|
||||||
|
|
||||||
|
# Back button
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("back_to_user_management_button"),
|
||||||
|
callback_data="admin_section:user_management"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int,
|
def get_user_card_keyboard(user_id: int,
|
||||||
is_banned: bool,
|
is_banned: bool,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
@@ -320,6 +384,12 @@ def get_user_card_keyboard(user_id: int,
|
|||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_ban_button"),
|
text=_(key="user_card_ban_button"),
|
||||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||||
|
builder.button(
|
||||||
|
text=_(
|
||||||
|
key="user_card_open_profile_button"
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_back_to_banned_list_button"),
|
text=_(key="user_card_back_to_banned_list_button"),
|
||||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||||
@@ -347,16 +417,13 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
|||||||
|
|
||||||
# Row: target selection (all / active / inactive)
|
# Row: target selection (all / active / inactive)
|
||||||
target_all_label = _(
|
target_all_label = _(
|
||||||
key="broadcast_target_all_button",
|
key="broadcast_target_all_button"
|
||||||
default="👥 Все"
|
|
||||||
)
|
)
|
||||||
target_active_label = _(
|
target_active_label = _(
|
||||||
key="broadcast_target_active_button",
|
key="broadcast_target_active_button"
|
||||||
default="✅ Активные"
|
|
||||||
)
|
)
|
||||||
target_inactive_label = _(
|
target_inactive_label = _(
|
||||||
key="broadcast_target_inactive_button",
|
key="broadcast_target_inactive_button"
|
||||||
default="⌛ Неактивные"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Highlight current selection with a prefix
|
# Highlight current selection with a prefix
|
||||||
@@ -378,9 +445,9 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
|||||||
builder.adjust(3)
|
builder.adjust(3)
|
||||||
|
|
||||||
# Row: confirmation
|
# Row: confirmation
|
||||||
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
|
builder.button(text=_(key="confirm_broadcast_send_button"),
|
||||||
callback_data="broadcast_final_action:send")
|
callback_data="broadcast_final_action:send")
|
||||||
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
|
builder.button(text=_(key="cancel_broadcast_button"),
|
||||||
callback_data="broadcast_final_action:cancel")
|
callback_data="broadcast_final_action:cancel")
|
||||||
builder.adjust(2)
|
builder.adjust(2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|||||||
@@ -91,19 +91,31 @@ def get_trial_confirmation_keyboard(lang: str,
|
|||||||
|
|
||||||
|
|
||||||
def get_subscription_options_keyboard(subscription_options: Dict[
|
def get_subscription_options_keyboard(subscription_options: Dict[
|
||||||
int, Optional[int]], currency_symbol_val: str, lang: str,
|
float, Optional[float]], currency_symbol_val: str, lang: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
def _format_gb(val: float) -> str:
|
||||||
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
if subscription_options:
|
if subscription_options:
|
||||||
for months, price in subscription_options.items():
|
for months, price in subscription_options.items():
|
||||||
if price is not None:
|
if price is not None:
|
||||||
|
if traffic_mode:
|
||||||
|
button_text = _(
|
||||||
|
"buy_traffic_package_button",
|
||||||
|
traffic_gb=_format_gb(months),
|
||||||
|
price=price,
|
||||||
|
currency_symbol=currency_symbol_val,
|
||||||
|
)
|
||||||
|
callback_data = f"subscribe_period:{_format_gb(months)}"
|
||||||
|
else:
|
||||||
button_text = _("subscribe_for_months_button",
|
button_text = _("subscribe_for_months_button",
|
||||||
months=months,
|
months=months,
|
||||||
price=price,
|
price=price,
|
||||||
currency_symbol=currency_symbol_val)
|
currency_symbol=currency_symbol_val)
|
||||||
|
callback_data = f"subscribe_period:{months}"
|
||||||
builder.button(text=button_text,
|
builder.button(text=button_text,
|
||||||
callback_data=f"subscribe_period:{months}")
|
callback_data=callback_data)
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||||
@@ -112,26 +124,46 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
|||||||
|
|
||||||
|
|
||||||
def get_payment_method_keyboard(months: int, price: float,
|
def get_payment_method_keyboard(months: int, price: float,
|
||||||
tribute_url: Optional[str],
|
|
||||||
stars_price: Optional[int],
|
stars_price: Optional[int],
|
||||||
currency_symbol_val: str, lang: str,
|
currency_symbol_val: str, lang: str,
|
||||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
if settings.FREEKASSA_ENABLED:
|
def _format_value(val: float) -> str:
|
||||||
builder.button(text=_("pay_with_sbp_button"),
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
callback_data=f"pay_fk:{months}:{price}")
|
value_str = _format_value(months)
|
||||||
if settings.YOOKASSA_ENABLED:
|
mode_suffix = f":{sale_mode}"
|
||||||
builder.button(text=_("pay_with_yookassa_button"),
|
for method in settings.payment_methods_order:
|
||||||
callback_data=f"pay_yk:{months}:{price}")
|
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
|
||||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
builder.button(
|
||||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
text=_("pay_with_severpay_button"),
|
||||||
if settings.STARS_ENABLED and stars_price is not None:
|
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
|
||||||
builder.button(text=_("pay_with_stars_button"),
|
)
|
||||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
|
||||||
if settings.CRYPTOPAY_ENABLED:
|
builder.button(
|
||||||
builder.button(text=_("pay_with_cryptopay_button"),
|
text=_("pay_with_sbp_button"),
|
||||||
callback_data=f"pay_crypto:{months}:{price}")
|
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
|
||||||
|
)
|
||||||
|
elif method == "platega" and settings.PLATEGA_ENABLED:
|
||||||
|
builder.button(
|
||||||
|
text=_("pay_with_platega_button"),
|
||||||
|
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
|
||||||
|
)
|
||||||
|
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||||
|
builder.button(
|
||||||
|
text=_("pay_with_yookassa_button"),
|
||||||
|
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
|
||||||
|
)
|
||||||
|
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
|
||||||
|
builder.button(
|
||||||
|
text=_("pay_with_stars_button"),
|
||||||
|
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
|
||||||
|
)
|
||||||
|
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
|
||||||
|
builder.button(
|
||||||
|
text=_("pay_with_cryptopay_button"),
|
||||||
|
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
|
||||||
|
)
|
||||||
builder.button(text=_(key="cancel_button"),
|
builder.button(text=_(key="cancel_button"),
|
||||||
callback_data="main_action:subscribe")
|
callback_data="main_action:subscribe")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
@@ -162,28 +194,33 @@ def get_yk_autopay_choice_keyboard(
|
|||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
has_saved_cards: bool = True,
|
has_saved_cards: bool = True,
|
||||||
|
sale_mode: str = "subscription",
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
price_str = str(price)
|
price_str = str(price)
|
||||||
|
def _format_value(val: float) -> str:
|
||||||
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
|
value_str = _format_value(months)
|
||||||
|
suffix = f":{sale_mode}"
|
||||||
if has_saved_cards:
|
if has_saved_cards:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="back_to_payment_methods_button"),
|
text=_(key="back_to_payment_methods_button"),
|
||||||
callback_data=f"subscribe_period:{months}",
|
callback_data=f"subscribe_period:{value_str}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
@@ -196,6 +233,7 @@ def get_yk_saved_cards_keyboard(
|
|||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
|
sale_mode: str = "subscription",
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
"""Paginated keyboard for selecting a saved YooKassa card."""
|
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
@@ -205,12 +243,16 @@ def get_yk_saved_cards_keyboard(
|
|||||||
start = page * per_page
|
start = page * per_page
|
||||||
end = min(total, start + per_page)
|
end = min(total, start + per_page)
|
||||||
price_str = str(price)
|
price_str = str(price)
|
||||||
|
def _format_value(val: float) -> str:
|
||||||
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
|
value_str = _format_value(months)
|
||||||
|
suffix = f":{sale_mode}"
|
||||||
|
|
||||||
for method_id, title in cards[start:end]:
|
for method_id, title in cards[start:end]:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=title,
|
text=title,
|
||||||
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,14 +261,14 @@ def get_yk_saved_cards_keyboard(
|
|||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="⬅️",
|
text="⬅️",
|
||||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if end < total:
|
if end < total:
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="➡️",
|
text="➡️",
|
||||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if nav_buttons:
|
if nav_buttons:
|
||||||
@@ -235,13 +277,13 @@ def get_yk_saved_cards_keyboard(
|
|||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="back_to_autopay_method_choice_button"),
|
text=_(key="back_to_autopay_method_choice_button"),
|
||||||
callback_data=f"pay_yk:{months}:{price_str}",
|
callback_data=f"pay_yk:{value_str}:{price_str}{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
@@ -333,10 +375,12 @@ def get_connect_and_main_keyboard(
|
|||||||
i18n_instance,
|
i18n_instance,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
config_link: Optional[str],
|
config_link: Optional[str],
|
||||||
|
connect_button_url: Optional[str] = None,
|
||||||
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||||
"""Keyboard with a connect button and a back to main menu button."""
|
"""Keyboard with a connect button and a back to main menu button."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
button_target = connect_button_url or config_link
|
||||||
|
|
||||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||||
builder.row(
|
builder.row(
|
||||||
@@ -345,9 +389,9 @@ def get_connect_and_main_keyboard(
|
|||||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif config_link:
|
elif button_target:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
builder.row(
|
builder.row(
|
||||||
|
|||||||
+2
-2
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.stars_service import StarsService
|
from bot.services.stars_service import StarsService
|
||||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
|
||||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||||
|
|
||||||
from bot.handlers.user import payment as user_payment_webhook_module
|
from bot.handlers.user import payment as user_payment_webhook_module
|
||||||
@@ -200,13 +199,14 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
"panel_service",
|
"panel_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
"freekassa_service",
|
"freekassa_service",
|
||||||
"tribute_service",
|
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
"promo_code_service",
|
"promo_code_service",
|
||||||
"stars_service",
|
"stars_service",
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
"referral_service",
|
"referral_service",
|
||||||
|
"platega_service",
|
||||||
|
"severpay_service",
|
||||||
):
|
):
|
||||||
await close_service(service_key)
|
await close_service(service_key)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
|||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
|
|
||||||
class CryptoPayService:
|
class CryptoPayService:
|
||||||
@@ -63,6 +64,7 @@ class CryptoPayService:
|
|||||||
months: int,
|
months: int,
|
||||||
amount: float,
|
amount: float,
|
||||||
description: str,
|
description: str,
|
||||||
|
sale_mode: str = "subscription",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if not self.configured or not self.client:
|
if not self.configured or not self.client:
|
||||||
logging.error("CryptoPayService not configured")
|
logging.error("CryptoPayService not configured")
|
||||||
@@ -78,7 +80,7 @@ class CryptoPayService:
|
|||||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||||
"status": "pending_cryptopay",
|
"status": "pending_cryptopay",
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": months,
|
"subscription_duration_months": int(months),
|
||||||
"provider": "cryptopay",
|
"provider": "cryptopay",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -94,6 +96,8 @@ class CryptoPayService:
|
|||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"subscription_months": str(months),
|
"subscription_months": str(months),
|
||||||
"payment_db_id": str(payment_record.payment_id),
|
"payment_db_id": str(payment_record.payment_id),
|
||||||
|
"sale_mode": sale_mode,
|
||||||
|
"traffic_gb": str(months) if sale_mode == "traffic" else None,
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
invoice = await self.client.create_invoice(
|
invoice = await self.client.create_invoice(
|
||||||
@@ -132,8 +136,10 @@ class CryptoPayService:
|
|||||||
try:
|
try:
|
||||||
meta = json.loads(invoice.payload)
|
meta = json.loads(invoice.payload)
|
||||||
user_id = int(meta["user_id"])
|
user_id = int(meta["user_id"])
|
||||||
months = int(meta["subscription_months"])
|
months = float(meta.get("subscription_months") or 0)
|
||||||
payment_db_id = int(meta["payment_db_id"])
|
payment_db_id = int(meta["payment_db_id"])
|
||||||
|
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
|
||||||
|
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||||
return
|
return
|
||||||
@@ -156,15 +162,19 @@ class CryptoPayService:
|
|||||||
activation = await subscription_service.activate_subscription(
|
activation = await subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
months,
|
int(months) if sale_mode != "traffic" else 0,
|
||||||
float(invoice.amount),
|
float(invoice.amount),
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
provider="cryptopay",
|
provider="cryptopay",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||||
)
|
)
|
||||||
|
referral_bonus = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
months,
|
int(months) or 1,
|
||||||
current_payment_db_id=payment_db_id,
|
current_payment_db_id=payment_db_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
)
|
)
|
||||||
@@ -179,14 +189,21 @@ class CryptoPayService:
|
|||||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||||
|
|
||||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
|
display_link, button_link = prepare_config_links(settings, raw_config_link)
|
||||||
|
config_link_text = display_link or _("config_link_not_available")
|
||||||
final_end = activation.get("end_date")
|
final_end = activation.get("end_date")
|
||||||
applied_days = 0
|
applied_days = 0
|
||||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
final_end = referral_bonus["referee_new_end_date"]
|
final_end = referral_bonus["referee_new_end_date"]
|
||||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||||
|
|
||||||
if applied_days:
|
if sale_mode == "traffic":
|
||||||
|
text = _("payment_successful_traffic_full",
|
||||||
|
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
|
||||||
|
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||||
|
config_link=config_link_text)
|
||||||
|
elif applied_days:
|
||||||
inviter_name_display = _("friend_placeholder")
|
inviter_name_display = _("friend_placeholder")
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
@@ -197,20 +214,25 @@ class CryptoPayService:
|
|||||||
elif inviter.username:
|
elif inviter.username:
|
||||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
text = _("payment_successful_with_referral_bonus_full",
|
text = _("payment_successful_with_referral_bonus_full",
|
||||||
months=months,
|
months=int(months),
|
||||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||||
bonus_days=applied_days,
|
bonus_days=applied_days,
|
||||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
inviter_name=inviter_name_display,
|
inviter_name=inviter_name_display,
|
||||||
config_link=config_link)
|
config_link=config_link_text)
|
||||||
else:
|
else:
|
||||||
text = _("payment_successful_full",
|
text = _("payment_successful_full",
|
||||||
months=months,
|
months=int(months),
|
||||||
end_date=final_end.strftime('%Y-%m-%d'),
|
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||||
config_link=config_link)
|
config_link=config_link_text)
|
||||||
|
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
lang, i18n, settings, config_link, preserve_message=True
|
lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
display_link,
|
||||||
|
connect_button_url=button_link,
|
||||||
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
@@ -231,7 +253,8 @@ class CryptoPayService:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=float(invoice.amount),
|
amount=float(invoice.amount),
|
||||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||||
months=months,
|
months=int(months) if sale_mode != "traffic" else 0,
|
||||||
|
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||||
payment_provider="crypto_pay",
|
payment_provider="crypto_pay",
|
||||||
username=user.username if user else None
|
username=user.username if user else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
|||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
|
|
||||||
class FreeKassaService:
|
class FreeKassaService:
|
||||||
@@ -284,20 +285,25 @@ class FreeKassaService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
months = payment.subscription_duration_months or 1
|
months = payment.subscription_duration_months or 1
|
||||||
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
|
|
||||||
activation = await self.subscription_service.activate_subscription(
|
activation = await self.subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
payment.user_id,
|
payment.user_id,
|
||||||
months,
|
int(months) if sale_mode != "traffic" else 0,
|
||||||
float(payment.amount),
|
float(payment.amount),
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
provider="freekassa",
|
provider="freekassa",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=months if sale_mode == "traffic" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
referral_bonus = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
session,
|
session,
|
||||||
payment.user_id,
|
payment.user_id,
|
||||||
months,
|
int(months),
|
||||||
current_payment_db_id=payment.payment_id,
|
current_payment_db_id=payment.payment_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
)
|
)
|
||||||
@@ -312,12 +318,12 @@ class FreeKassaService:
|
|||||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
config_link = None
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
final_end = None
|
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
final_end = activation.get("end_date") if activation else None
|
||||||
months = payment.subscription_duration_months or 1
|
months = payment.subscription_duration_months or 1
|
||||||
if activation:
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
config_link = activation.get("subscription_url")
|
|
||||||
final_end = activation.get("end_date")
|
|
||||||
|
|
||||||
applied_days = 0
|
applied_days = 0
|
||||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
@@ -327,14 +333,19 @@ class FreeKassaService:
|
|||||||
if not final_end and activation and activation.get("end_date"):
|
if not final_end and activation and activation.get("end_date"):
|
||||||
final_end = activation["end_date"]
|
final_end = activation["end_date"]
|
||||||
|
|
||||||
if not config_link:
|
|
||||||
config_link = _("config_link_not_available")
|
|
||||||
if final_end:
|
if final_end:
|
||||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||||
else:
|
else:
|
||||||
end_date_str = _("config_link_not_available")
|
end_date_str = _("config_link_not_available")
|
||||||
|
|
||||||
if applied_days:
|
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||||
|
|
||||||
|
if sale_mode == "traffic":
|
||||||
|
text = _("payment_successful_traffic_full",
|
||||||
|
traffic_gb=traffic_label,
|
||||||
|
end_date=end_date_str if final_end else "",
|
||||||
|
config_link=config_link_text)
|
||||||
|
elif applied_days:
|
||||||
inviter_name_display = _("friend_placeholder")
|
inviter_name_display = _("friend_placeholder")
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
@@ -351,14 +362,14 @@ class FreeKassaService:
|
|||||||
bonus_days=applied_days,
|
bonus_days=applied_days,
|
||||||
final_end_date=end_date_str,
|
final_end_date=end_date_str,
|
||||||
inviter_name=inviter_name_display,
|
inviter_name=inviter_name_display,
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
text = _(
|
text = _(
|
||||||
"payment_successful_full",
|
"payment_successful_full",
|
||||||
months=months,
|
months=months,
|
||||||
end_date=end_date_str,
|
end_date=end_date_str,
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
if provider_payment_id:
|
if provider_payment_id:
|
||||||
order_info_text = _(
|
order_info_text = _(
|
||||||
@@ -372,7 +383,8 @@ class FreeKassaService:
|
|||||||
lang,
|
lang,
|
||||||
self.i18n,
|
self.i18n,
|
||||||
self.settings,
|
self.settings,
|
||||||
config_link,
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
preserve_message=True,
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -392,7 +404,8 @@ class FreeKassaService:
|
|||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
currency=self.default_currency,
|
currency=self.default_currency,
|
||||||
months=months,
|
months=int(months) if sale_mode != "traffic" else 0,
|
||||||
|
traffic_gb=months if sale_mode == "traffic" else None,
|
||||||
payment_provider="freekassa",
|
payment_provider="freekassa",
|
||||||
username=db_user.username if db_user else None,
|
username=db_user.username if db_user else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.utils.text_decorations import html_decoration as hd
|
from aiogram.utils.text_decorations import html_decoration as hd
|
||||||
from aiogram.exceptions import TelegramRetryAfter
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Union, Dict, Any
|
from typing import Optional, Union, Dict, Any, Callable
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -14,6 +15,10 @@ from bot.utils.text_sanitizer import (
|
|||||||
display_name_or_fallback,
|
display_name_or_fallback,
|
||||||
username_for_display,
|
username_for_display,
|
||||||
)
|
)
|
||||||
|
from bot.utils.telegram_markup import (
|
||||||
|
is_profile_link_error,
|
||||||
|
remove_profile_link_buttons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -35,7 +40,42 @@ class NotificationService:
|
|||||||
base_display = f"{base_display} ({username_for_display(username)})"
|
base_display = f"{base_display} ({username_for_display(username)})"
|
||||||
return base_display
|
return base_display
|
||||||
|
|
||||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
@staticmethod
|
||||||
|
def _build_profile_keyboard(
|
||||||
|
translate: Callable[..., str],
|
||||||
|
user_id: int,
|
||||||
|
referrer_id: Optional[int] = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_profile_link",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
if referrer_id:
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_referrer_profile_button",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={referrer_id}",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
async def _send_to_log_channel(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
thread_id: Optional[int] = None,
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
):
|
||||||
"""Send message to configured log channel/group using message queue"""
|
"""Send message to configured log channel/group using message queue"""
|
||||||
if not self.settings.LOG_CHAT_ID:
|
if not self.settings.LOG_CHAT_ID:
|
||||||
return
|
return
|
||||||
@@ -43,13 +83,42 @@ class NotificationService:
|
|||||||
queue_manager = get_queue_manager()
|
queue_manager = get_queue_manager()
|
||||||
if not queue_manager:
|
if not queue_manager:
|
||||||
logging.warning("Message queue manager not available, falling back to direct send")
|
logging.warning("Message queue manager not available, falling back to direct send")
|
||||||
|
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||||
|
|
||||||
|
def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]:
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"chat_id": self.settings.LOG_CHAT_ID,
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
if markup:
|
||||||
|
kwargs["reply_markup"] = markup
|
||||||
|
if final_thread_id:
|
||||||
|
kwargs["message_thread_id"] = final_thread_id
|
||||||
|
return kwargs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(**_build_kwargs(reply_markup))
|
||||||
chat_id=self.settings.LOG_CHAT_ID,
|
except TelegramBadRequest as exc:
|
||||||
text=message,
|
if is_profile_link_error(exc):
|
||||||
parse_mode="HTML",
|
fallback_markup = remove_profile_link_buttons(reply_markup)
|
||||||
disable_web_page_preview=True,
|
logging.warning(
|
||||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
"Telegram rejected profile buttons for log chat %s: %s. "
|
||||||
|
"Retrying without tg:// links.",
|
||||||
|
self.settings.LOG_CHAT_ID,
|
||||||
|
getattr(exc, "message", "") or str(exc),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(**_build_kwargs(fallback_markup))
|
||||||
|
except Exception as retry_exc:
|
||||||
|
logging.error(
|
||||||
|
"Failed to send notification without profile buttons to log "
|
||||||
|
f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||||
@@ -64,6 +133,8 @@ class NotificationService:
|
|||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
"disable_web_page_preview": True
|
"disable_web_page_preview": True
|
||||||
}
|
}
|
||||||
|
if reply_markup:
|
||||||
|
kwargs["reply_markup"] = reply_markup
|
||||||
|
|
||||||
# Add thread ID for supergroups if specified
|
# Add thread ID for supergroups if specified
|
||||||
if final_thread_id:
|
if final_thread_id:
|
||||||
@@ -124,14 +195,14 @@ class NotificationService:
|
|||||||
|
|
||||||
referral_text = ""
|
referral_text = ""
|
||||||
if referred_by_id:
|
if referred_by_id:
|
||||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||||
|
referral_text = _(
|
||||||
|
"log_referral_suffix",
|
||||||
|
referrer_link=referrer_link,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_new_user_registration",
|
"log_new_user_registration",
|
||||||
default="👤 <b>Новый пользователь</b>\n\n"
|
|
||||||
"🆔 ID: <code>{user_id}</code>\n"
|
|
||||||
"👤 Имя: {user_display}{referral_text}\n"
|
|
||||||
"📅 Время: {timestamp}",
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
user_display=user_display,
|
user_display=user_display,
|
||||||
referral_text=referral_text,
|
referral_text=referral_text,
|
||||||
@@ -139,11 +210,13 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||||
months: int, payment_provider: str,
|
months: int, payment_provider: str,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None,
|
||||||
|
traffic_gb: Optional[float] = None):
|
||||||
"""Send notification about successful payment"""
|
"""Send notification about successful payment"""
|
||||||
if not self.settings.LOG_PAYMENTS:
|
if not self.settings.LOG_PAYMENTS:
|
||||||
return
|
return
|
||||||
@@ -161,17 +234,25 @@ class NotificationService:
|
|||||||
"freekassa": "💳",
|
"freekassa": "💳",
|
||||||
"cryptopay": "₿",
|
"cryptopay": "₿",
|
||||||
"stars": "⭐",
|
"stars": "⭐",
|
||||||
"tribute": "💎"
|
"platega": "💳",
|
||||||
|
"severpay": "💳",
|
||||||
}.get(payment_provider.lower(), "💰")
|
}.get(payment_provider.lower(), "💰")
|
||||||
|
|
||||||
|
if traffic_gb is not None:
|
||||||
|
traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
|
||||||
|
message = _(
|
||||||
|
"log_payment_received_traffic",
|
||||||
|
provider_emoji=provider_emoji,
|
||||||
|
user_display=user_display,
|
||||||
|
amount=amount,
|
||||||
|
currency=currency,
|
||||||
|
traffic_gb=traffic_label,
|
||||||
|
payment_provider=payment_provider,
|
||||||
|
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
else:
|
||||||
message = _(
|
message = _(
|
||||||
"log_payment_received",
|
"log_payment_received",
|
||||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
|
||||||
"👤 Пользователь: {user_display}\n"
|
|
||||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
|
||||||
"📅 Период: <b>{months} мес.</b>\n"
|
|
||||||
"🏦 Провайдер: {payment_provider}\n"
|
|
||||||
"🕐 Время: {timestamp}",
|
|
||||||
provider_emoji=provider_emoji,
|
provider_emoji=provider_emoji,
|
||||||
user_display=user_display,
|
user_display=user_display,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
@@ -182,7 +263,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -200,11 +282,6 @@ class NotificationService:
|
|||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_promo_activation",
|
"log_promo_activation",
|
||||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
|
||||||
"👤 Пользователь: {user_display}\n"
|
|
||||||
"🏷 Код: <code>{promo_code}</code>\n"
|
|
||||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
|
||||||
"🕐 Время: {timestamp}",
|
|
||||||
user_display=user_display,
|
user_display=user_display,
|
||||||
promo_code=promo_code,
|
promo_code=promo_code,
|
||||||
bonus_days=bonus_days,
|
bonus_days=bonus_days,
|
||||||
@@ -212,7 +289,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -230,17 +308,14 @@ class NotificationService:
|
|||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_trial_activation",
|
"log_trial_activation",
|
||||||
default="🆓 <b>Активирован триал</b>\n\n"
|
|
||||||
"👤 Пользователь: {user_display}\n"
|
|
||||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
|
||||||
"🕐 Время: {timestamp}",
|
|
||||||
user_display=user_display,
|
user_display=user_display,
|
||||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_panel_sync(self, status: str, details: str,
|
async def notify_panel_sync(self, status: str, details: str,
|
||||||
users_processed: int, subs_synced: int,
|
users_processed: int, subs_synced: int,
|
||||||
@@ -261,12 +336,6 @@ class NotificationService:
|
|||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_panel_sync",
|
"log_panel_sync",
|
||||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
|
||||||
"📊 Статус: <b>{status}</b>\n"
|
|
||||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
|
||||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
|
||||||
"🕐 Время: {timestamp}\n\n"
|
|
||||||
"📝 Детали:\n{details}",
|
|
||||||
status_emoji=status_emoji,
|
status_emoji=status_emoji,
|
||||||
status=status,
|
status=status,
|
||||||
users_processed=users_processed,
|
users_processed=users_processed,
|
||||||
@@ -297,18 +366,14 @@ class NotificationService:
|
|||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_suspicious_promo",
|
"log_suspicious_promo",
|
||||||
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
|
|
||||||
"👤 Пользователь: {user_display}\n"
|
|
||||||
"🆔 ID: <code>{user_id}</code>\n"
|
|
||||||
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
|
|
||||||
"🕐 Время: {timestamp}",
|
|
||||||
user_display=hd.quote(user_display),
|
user_display=hd.quote(user_display),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
suspicious_input=hd.quote(suspicious_input),
|
suspicious_input=hd.quote(suspicious_input),
|
||||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -339,15 +340,17 @@ class PanelApiService:
|
|||||||
default_traffic_limit_strategy: str = "NO_RESET",
|
default_traffic_limit_strategy: str = "NO_RESET",
|
||||||
hwid_device_limit: Optional[int] = None,
|
hwid_device_limit: Optional[int] = None,
|
||||||
specific_squad_uuids: Optional[List[str]] = None,
|
specific_squad_uuids: Optional[List[str]] = None,
|
||||||
|
external_squad_uuid: Optional[str] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
status: str = "ACTIVE",
|
status: str = "ACTIVE",
|
||||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||||
|
|
||||||
if not (6 <= len(username_on_panel) <= 34 and
|
username_is_valid = (
|
||||||
username_on_panel.replace('_', '').replace('-', '').isalnum()):
|
3 <= len(username_on_panel) <= 36
|
||||||
if not (username_on_panel.startswith("tg_")
|
and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None
|
||||||
and username_on_panel.split("tg_")[-1].isdigit()):
|
)
|
||||||
|
if not username_is_valid:
|
||||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||||
logging.error(msg)
|
logging.error(msg)
|
||||||
return {
|
return {
|
||||||
@@ -383,6 +386,8 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
if specific_squad_uuids:
|
if specific_squad_uuids:
|
||||||
payload["activeInternalSquads"] = specific_squad_uuids
|
payload["activeInternalSquads"] = specific_squad_uuids
|
||||||
|
if external_squad_uuid:
|
||||||
|
payload["externalSquadUuid"] = external_squad_uuid
|
||||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||||
if email: payload["email"] = email
|
if email: payload["email"] = email
|
||||||
if description: payload["description"] = description
|
if description: payload["description"] = description
|
||||||
@@ -455,6 +460,37 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def delete_user_from_panel(self,
|
||||||
|
user_uuid: str,
|
||||||
|
log_response: bool = True) -> bool:
|
||||||
|
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||||
|
endpoint = f"/users/{user_uuid}"
|
||||||
|
response_data = await self._request(
|
||||||
|
"DELETE", endpoint, log_full_response=log_response
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response_data:
|
||||||
|
logging.error(
|
||||||
|
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response_data.get("error"):
|
||||||
|
details = response_data.get("details") or {}
|
||||||
|
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||||
|
if error_code in {"A062", "A040"}:
|
||||||
|
logging.info(
|
||||||
|
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logging.error(
|
||||||
|
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_subscription_link(
|
async def get_subscription_link(
|
||||||
self,
|
self,
|
||||||
short_uuid_or_sub_uuid: str,
|
short_uuid_or_sub_uuid: str,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
from bot.utils.date_utils import add_months
|
|
||||||
|
|
||||||
EVENT_MAP = {
|
EVENT_MAP = {
|
||||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||||
@@ -44,128 +43,6 @@ class PanelWebhookService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||||
|
|
||||||
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
|
|
||||||
lang: str, markup, first_name: str) -> bool:
|
|
||||||
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
|
|
||||||
|
|
||||||
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
|
|
||||||
"""
|
|
||||||
from db.dal import subscription_dal, payment_dal
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
try:
|
|
||||||
auto_renewed = False
|
|
||||||
# Check if user has tribute subscriptions that weren't cancelled
|
|
||||||
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
|
||||||
|
|
||||||
for sub in user_subs:
|
|
||||||
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
|
|
||||||
if sub.status_from_panel == 'CANCELLED':
|
|
||||||
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if this user has tribute payments
|
|
||||||
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
|
|
||||||
|
|
||||||
if last_tribute_duration is not None:
|
|
||||||
# This user has tribute payments, auto-renew for the same duration
|
|
||||||
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
|
|
||||||
|
|
||||||
# Extend subscription by the last payment duration (calendar months)
|
|
||||||
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
|
|
||||||
|
|
||||||
# Update local DB subscription
|
|
||||||
await subscription_dal.update_subscription(
|
|
||||||
session,
|
|
||||||
sub.subscription_id,
|
|
||||||
{
|
|
||||||
'end_date': new_end_date,
|
|
||||||
'status_from_panel': 'ACTIVE',
|
|
||||||
'is_active': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# Update panel expiry to ensure actual service access is extended
|
|
||||||
try:
|
|
||||||
panel_payload = {
|
|
||||||
"uuid": sub.panel_user_uuid,
|
|
||||||
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
|
||||||
"status": "ACTIVE",
|
|
||||||
}
|
|
||||||
panel_update_resp = await self.panel_service.update_user_details_on_panel(
|
|
||||||
sub.panel_user_uuid,
|
|
||||||
panel_payload,
|
|
||||||
log_response=True,
|
|
||||||
)
|
|
||||||
if panel_update_resp:
|
|
||||||
logging.info(
|
|
||||||
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
|
|
||||||
)
|
|
||||||
except Exception as e_panel:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
|
|
||||||
|
|
||||||
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
|
|
||||||
try:
|
|
||||||
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
|
|
||||||
if last_payment and last_payment.amount and last_payment.currency:
|
|
||||||
provider_payment_id = (
|
|
||||||
f"tribute_auto_{user_id}_{sub.subscription_id}_"
|
|
||||||
f"{new_end_date.strftime('%Y%m%d')}"
|
|
||||||
)
|
|
||||||
created_payment = await payment_dal.ensure_payment_with_provider_id(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=float(last_payment.amount),
|
|
||||||
currency=last_payment.currency,
|
|
||||||
months=last_tribute_duration,
|
|
||||||
description="Auto-renewal (panel webhook)",
|
|
||||||
provider="tribute",
|
|
||||||
provider_payment_id=provider_payment_id,
|
|
||||||
)
|
|
||||||
if created_payment:
|
|
||||||
logging.info(
|
|
||||||
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.warning(
|
|
||||||
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
|
|
||||||
except Exception as e_pay:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send auto-renewal notification
|
|
||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
|
||||||
auto_renewal_msg = _(
|
|
||||||
"tribute_auto_renewal",
|
|
||||||
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
|
|
||||||
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
|
|
||||||
"Новая дата окончания: {end_date}",
|
|
||||||
user_name=first_name,
|
|
||||||
months=last_tribute_duration,
|
|
||||||
end_date=new_end_date.strftime('%Y-%m-%d')
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(
|
|
||||||
user_id,
|
|
||||||
auto_renewal_msg,
|
|
||||||
reply_markup=markup,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
auto_renewed = True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
return auto_renewed
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
|
|
||||||
await session.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def handle_event(self, event_name: str, user_payload: dict):
|
async def handle_event(self, event_name: str, user_payload: dict):
|
||||||
telegram_id = user_payload.get("telegramId")
|
telegram_id = user_payload.get("telegramId")
|
||||||
if not telegram_id:
|
if not telegram_id:
|
||||||
@@ -193,7 +70,7 @@ class PanelWebhookService:
|
|||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||||
try:
|
try:
|
||||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||||
@@ -208,7 +85,7 @@ class PanelWebhookService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||||
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
|
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||||
if days_left == 2:
|
if days_left == 2:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
@@ -220,7 +97,7 @@ class PanelWebhookService:
|
|||||||
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
||||||
getattr(sub, 'provider', None) if sub else None,
|
getattr(sub, 'provider', None) if sub else None,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||||
await self._send_message(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -239,11 +116,7 @@ class PanelWebhookService:
|
|||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=user_payload.get("expireAt", "")[:10],
|
||||||
)
|
)
|
||||||
elif event_name == "user.expired":
|
elif event_name == "user.expired":
|
||||||
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
|
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
|
|
||||||
|
|
||||||
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
|
|
||||||
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
|
||||||
await self._send_message(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
lang,
|
lang,
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
from bot.services.notification_service import NotificationService
|
||||||
|
from db.dal import payment_dal, user_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
|
|
||||||
|
class PlategaService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
default_return_url: str,
|
||||||
|
):
|
||||||
|
self.bot = bot
|
||||||
|
self.settings = settings
|
||||||
|
self.i18n = i18n
|
||||||
|
self.async_session_factory = async_session_factory
|
||||||
|
self.subscription_service = subscription_service
|
||||||
|
self.referral_service = referral_service
|
||||||
|
|
||||||
|
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||||
|
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||||
|
self.secret = settings.PLATEGA_SECRET
|
||||||
|
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
|
||||||
|
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||||
|
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
|
||||||
|
|
||||||
|
self._timeout = ClientTimeout(total=20)
|
||||||
|
self._session: Optional[ClientSession] = None
|
||||||
|
self._auth_headers = {
|
||||||
|
"X-MerchantId": self.merchant_id or "",
|
||||||
|
"X-Secret": self.secret or "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
self.configured: bool = bool(
|
||||||
|
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
|
||||||
|
)
|
||||||
|
if not self.configured:
|
||||||
|
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
|
||||||
|
|
||||||
|
async def _get_session(self) -> ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession(timeout=self._timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def create_transaction(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payment_db_id: int,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
|
description: str,
|
||||||
|
payload: Optional[str] = None,
|
||||||
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
if not self.configured:
|
||||||
|
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||||
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.base_url}/transaction/process"
|
||||||
|
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||||
|
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"paymentMethod": int(self.payment_method),
|
||||||
|
"paymentDetails": {"amount": float(amount), "currency": currency_code},
|
||||||
|
"description": description,
|
||||||
|
"return": self.return_url,
|
||||||
|
"failedUrl": self.failed_url,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove optional keys with falsy values to avoid validation errors
|
||||||
|
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging.error("Platega create_transaction: invalid JSON response: %s", response_text)
|
||||||
|
return False, {
|
||||||
|
"status": response.status,
|
||||||
|
"message": "invalid_json",
|
||||||
|
"raw": response_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.status != 200:
|
||||||
|
logging.error(
|
||||||
|
"Platega create_transaction: API returned error (status=%s, body=%s)",
|
||||||
|
response.status,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
return False, {"status": response.status, "message": response_data}
|
||||||
|
|
||||||
|
return True, response_data
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
|
||||||
|
return False, {"message": str(exc)}
|
||||||
|
|
||||||
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
|
if not self.configured:
|
||||||
|
return web.Response(status=503, text="platega_disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Platega webhook: failed to parse JSON: %s", exc)
|
||||||
|
return web.Response(status=400, text="bad_request")
|
||||||
|
|
||||||
|
header_merchant = request.headers.get("X-MerchantId")
|
||||||
|
header_secret = request.headers.get("X-Secret")
|
||||||
|
if header_merchant != self.merchant_id or header_secret != self.secret:
|
||||||
|
logging.error("Platega webhook: invalid auth headers")
|
||||||
|
return web.Response(status=403, text="forbidden")
|
||||||
|
|
||||||
|
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
|
||||||
|
status = str(data.get("status") or "").upper()
|
||||||
|
amount_raw = data.get("amount")
|
||||||
|
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
|
||||||
|
if not transaction_id or not status:
|
||||||
|
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
|
||||||
|
return web.Response(status=400, text="missing_fields")
|
||||||
|
|
||||||
|
async with self.async_session_factory() as session:
|
||||||
|
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
|
||||||
|
if not payment:
|
||||||
|
logging.error("Platega webhook: payment not found for transaction %s", transaction_id)
|
||||||
|
return web.Response(status=404, text="payment_not_found")
|
||||||
|
|
||||||
|
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
payment_months = payment.subscription_duration_months or 1
|
||||||
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
|
|
||||||
|
if status == "CONFIRMED":
|
||||||
|
if amount_raw is not None:
|
||||||
|
try:
|
||||||
|
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if incoming_amount != expected_amount:
|
||||||
|
logging.warning(
|
||||||
|
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||||
|
payment.payment_id,
|
||||||
|
expected_amount,
|
||||||
|
incoming_amount,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment.payment_id,
|
||||||
|
transaction_id,
|
||||||
|
"succeeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
activation = await self.subscription_service.activate_subscription(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
int(payment_months) if sale_mode != "traffic" else 0,
|
||||||
|
float(payment.amount),
|
||||||
|
payment.payment_id,
|
||||||
|
provider="platega",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_bonus = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
int(payment_months),
|
||||||
|
current_payment_db_id=payment.payment_id,
|
||||||
|
skip_if_active_before_payment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
|
||||||
|
return web.Response(status=500, text="processing_error")
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
|
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
final_end = activation.get("end_date") if activation else None
|
||||||
|
applied_days = 0
|
||||||
|
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||||
|
|
||||||
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
|
final_end = referral_bonus["referee_new_end_date"]
|
||||||
|
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||||
|
|
||||||
|
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||||
|
|
||||||
|
if sale_mode == "traffic":
|
||||||
|
text = _(
|
||||||
|
"payment_successful_traffic_full",
|
||||||
|
traffic_gb=traffic_label,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
elif applied_days:
|
||||||
|
inviter_name_display = _("friend_placeholder")
|
||||||
|
if db_user and db_user.referred_by_id:
|
||||||
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
|
if inviter:
|
||||||
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
|
if safe_name:
|
||||||
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
|
|
||||||
|
text = _(
|
||||||
|
"payment_successful_with_referral_bonus_full",
|
||||||
|
months=payment_months,
|
||||||
|
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
bonus_days=applied_days,
|
||||||
|
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
inviter_name=inviter_name_display,
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
elif applied_promo_days and final_end:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_with_promo_full",
|
||||||
|
months=payment_months,
|
||||||
|
bonus_days=applied_promo_days,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d"),
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_full",
|
||||||
|
months=payment_months,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang,
|
||||||
|
self.i18n,
|
||||||
|
self.settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
preserve_message=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
payment.user_id,
|
||||||
|
text,
|
||||||
|
reply_markup=markup,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||||
|
await notification_service.notify_payment_received(
|
||||||
|
user_id=payment.user_id,
|
||||||
|
amount=float(payment.amount),
|
||||||
|
currency=currency,
|
||||||
|
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||||
|
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||||
|
payment_provider="platega",
|
||||||
|
username=db_user.username if db_user else None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Platega webhook: failed to notify admins: %s", exc)
|
||||||
|
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment.payment_id,
|
||||||
|
transaction_id,
|
||||||
|
"canceled",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
|
||||||
|
return web.Response(status=500, text="processing_error")
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return web.Response(text="ok_canceled")
|
||||||
|
|
||||||
|
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
||||||
|
return web.Response(status=202, text="status_ignored")
|
||||||
|
|
||||||
|
|
||||||
|
async def platega_webhook_route(request: web.Request) -> web.Response:
|
||||||
|
service: PlategaService = request.app["platega_service"]
|
||||||
|
return await service.webhook_route(request)
|
||||||
@@ -177,6 +177,8 @@ class ReferralService:
|
|||||||
"ACTIVE_BONUS",
|
"ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes":
|
"traffic_limit_bytes":
|
||||||
self.settings.user_traffic_limit_bytes,
|
self.settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled":
|
||||||
|
False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
@@ -255,9 +257,35 @@ class ReferralService:
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def generate_referral_link(self, bot_username: str,
|
async def generate_referral_link(self, session: AsyncSession,
|
||||||
inviter_user_id: int) -> str:
|
bot_username: str,
|
||||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
inviter_user_id: int) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||||
|
if not user:
|
||||||
|
logging.warning(
|
||||||
|
"Unable to generate referral link: user %s not found.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||||
|
if not referral_code:
|
||||||
|
logging.warning(
|
||||||
|
"User %s has no referral code even after regeneration attempt.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s: %s",
|
||||||
|
inviter_user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||||
"""Get referral statistics for a user"""
|
"""Get referral statistics for a user"""
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
from bot.services.notification_service import NotificationService
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
from db.dal import payment_dal, user_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
|
|
||||||
|
class SeverPayService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
default_return_url: str,
|
||||||
|
):
|
||||||
|
self.bot = bot
|
||||||
|
self.settings = settings
|
||||||
|
self.i18n = i18n
|
||||||
|
self.async_session_factory = async_session_factory
|
||||||
|
self.subscription_service = subscription_service
|
||||||
|
self.referral_service = referral_service
|
||||||
|
|
||||||
|
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
|
||||||
|
self.mid = settings.SEVERPAY_MID
|
||||||
|
self.token = settings.SEVERPAY_TOKEN or ""
|
||||||
|
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||||
|
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
|
||||||
|
|
||||||
|
self._timeout = ClientTimeout(total=15)
|
||||||
|
self._session: Optional[ClientSession] = None
|
||||||
|
|
||||||
|
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
|
||||||
|
if not self.configured:
|
||||||
|
logging.warning("SeverPayService initialized but not fully configured. Payments disabled.")
|
||||||
|
|
||||||
|
async def _get_session(self) -> ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession(timeout=self._timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_amount(amount: float) -> str:
|
||||||
|
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"{quantized:.2f}"
|
||||||
|
|
||||||
|
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||||
|
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"mid": self.mid,
|
||||||
|
"salt": secrets.token_hex(8),
|
||||||
|
}
|
||||||
|
body.update(extra)
|
||||||
|
sorted_body = dict(sorted(body.items()))
|
||||||
|
sorted_body["sign"] = self._sign_payload(sorted_body)
|
||||||
|
return sorted_body
|
||||||
|
|
||||||
|
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
|
||||||
|
provided_sign = str(payload.get("sign") or "")
|
||||||
|
if not provided_sign or not self.token:
|
||||||
|
return False
|
||||||
|
# Webhook signatures are calculated on the original payload order (without sorting).
|
||||||
|
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||||
|
expected_sign = self._sign_payload(data)
|
||||||
|
return hmac.compare_digest(provided_sign, expected_sign)
|
||||||
|
|
||||||
|
async def create_payment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payment_db_id: int,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
|
description: str,
|
||||||
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
if not self.configured:
|
||||||
|
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||||
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.base_url}/payin/create"
|
||||||
|
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||||
|
amount_str = self._format_amount(amount)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"order_id": str(payment_db_id),
|
||||||
|
"amount": amount_str,
|
||||||
|
"currency": currency_code,
|
||||||
|
"client_email": f"{user_id}@telegram.org",
|
||||||
|
"client_id": str(user_id),
|
||||||
|
"url_return": self.return_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.lifetime_minutes:
|
||||||
|
body["lifetime"] = int(self.lifetime_minutes)
|
||||||
|
|
||||||
|
signed_body = self._build_signed_body(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=signed_body) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging.error("SeverPay create_payment: invalid JSON response: %s", response_text)
|
||||||
|
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||||
|
|
||||||
|
if response.status != 200 or not response_data.get("status"):
|
||||||
|
logging.error(
|
||||||
|
"SeverPay create_payment: API returned error (status=%s, body=%s)",
|
||||||
|
response.status,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
return False, {"status": response.status, "message": response_data}
|
||||||
|
|
||||||
|
return True, response_data.get("data") or response_data
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
|
||||||
|
return False, {"message": str(exc)}
|
||||||
|
|
||||||
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
|
if not self.configured:
|
||||||
|
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
|
||||||
|
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or not self._validate_signature(payload):
|
||||||
|
logging.error("SeverPay webhook: invalid signature or payload.")
|
||||||
|
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
|
||||||
|
|
||||||
|
event_type = str(payload.get("type") or "").lower()
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
|
||||||
|
if event_type != "payin" or not isinstance(data, dict):
|
||||||
|
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
|
||||||
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
|
provider_payment_id = str(data.get("id") or data.get("uid") or "")
|
||||||
|
order_id_raw = data.get("order_id")
|
||||||
|
status = str(data.get("status") or "").lower()
|
||||||
|
|
||||||
|
payment_db_id: Optional[int] = None
|
||||||
|
try:
|
||||||
|
if isinstance(order_id_raw, int):
|
||||||
|
payment_db_id = order_id_raw
|
||||||
|
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
|
||||||
|
payment_db_id = int(order_id_raw)
|
||||||
|
except Exception:
|
||||||
|
payment_db_id = None
|
||||||
|
|
||||||
|
async with self.async_session_factory() as session:
|
||||||
|
payment = None
|
||||||
|
if payment_db_id is not None:
|
||||||
|
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
|
if not payment and provider_payment_id:
|
||||||
|
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
|
||||||
|
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||||
|
|
||||||
|
payment_months = payment.subscription_duration_months or 1
|
||||||
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
|
if status == "success":
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment.payment_id,
|
||||||
|
provider_payment_id or str(payment.payment_id),
|
||||||
|
"succeeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
activation = await self.subscription_service.activate_subscription(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
int(payment_months) if sale_mode != "traffic" else 0,
|
||||||
|
float(payment.amount),
|
||||||
|
payment.payment_id,
|
||||||
|
provider="severpay",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_bonus = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
int(payment_months),
|
||||||
|
current_payment_db_id=payment.payment_id,
|
||||||
|
skip_if_active_before_payment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
|
||||||
|
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||||
|
|
||||||
|
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
|
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
||||||
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
final_end = activation.get("end_date") if activation else None
|
||||||
|
applied_days = 0
|
||||||
|
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||||
|
|
||||||
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
|
final_end = referral_bonus["referee_new_end_date"]
|
||||||
|
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||||
|
|
||||||
|
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||||
|
|
||||||
|
if sale_mode == "traffic":
|
||||||
|
text = _(
|
||||||
|
"payment_successful_traffic_full",
|
||||||
|
traffic_gb=traffic_label,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
elif applied_days:
|
||||||
|
inviter_name_display = _("friend_placeholder")
|
||||||
|
if db_user and db_user.referred_by_id:
|
||||||
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
|
if inviter:
|
||||||
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
|
if safe_name:
|
||||||
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
|
|
||||||
|
text = _(
|
||||||
|
"payment_successful_with_referral_bonus_full",
|
||||||
|
months=payment_months,
|
||||||
|
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
bonus_days=applied_days,
|
||||||
|
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
inviter_name=inviter_name_display,
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
elif applied_promo_days and final_end:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_with_promo_full",
|
||||||
|
months=payment_months,
|
||||||
|
bonus_days=applied_promo_days,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d"),
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_full",
|
||||||
|
months=payment_months,
|
||||||
|
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang,
|
||||||
|
self.i18n,
|
||||||
|
self.settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
preserve_message=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
payment.user_id,
|
||||||
|
text,
|
||||||
|
reply_markup=markup,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||||
|
await notification_service.notify_payment_received(
|
||||||
|
user_id=payment.user_id,
|
||||||
|
amount=float(payment.amount),
|
||||||
|
currency=payment.currency,
|
||||||
|
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||||
|
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||||
|
payment_provider="severpay",
|
||||||
|
username=db_user.username if db_user else None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
|
||||||
|
|
||||||
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
|
if status in {"fail", "decline"}:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment.payment_id,
|
||||||
|
provider_payment_id or str(payment.payment_id),
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
|
||||||
|
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||||
|
|
||||||
|
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
|
if status in {"process", "new"}:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment.payment_id,
|
||||||
|
provider_payment_id or str(payment.payment_id),
|
||||||
|
"pending_severpay",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
|
||||||
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
|
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
|
||||||
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def severpay_webhook_route(request: web.Request) -> web.Response:
|
||||||
|
service: SeverPayService = request.app["severpay_service"]
|
||||||
|
return await service.webhook_route(request)
|
||||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
|
||||||
|
|
||||||
class StarsService:
|
class StarsService:
|
||||||
@@ -26,14 +27,14 @@ class StarsService:
|
|||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
|
|
||||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||||
stars_price: int, description: str) -> Optional[int]:
|
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
|
||||||
payment_record_data = {
|
payment_record_data = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"amount": float(stars_price),
|
"amount": float(stars_price),
|
||||||
"currency": "XTR",
|
"currency": "XTR",
|
||||||
"status": "pending_stars",
|
"status": "pending_stars",
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": months,
|
"subscription_duration_months": int(months),
|
||||||
"provider": "telegram_stars",
|
"provider": "telegram_stars",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -46,7 +47,7 @@ class StarsService:
|
|||||||
exc_info=True)
|
exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = f"{db_payment_record.payment_id}:{months}"
|
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
|
||||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||||
try:
|
try:
|
||||||
await self.bot.send_invoice(
|
await self.bot.send_invoice(
|
||||||
@@ -69,7 +70,8 @@ class StarsService:
|
|||||||
payment_db_id: int,
|
payment_db_id: int,
|
||||||
months: int,
|
months: int,
|
||||||
stars_amount: int,
|
stars_amount: int,
|
||||||
i18n_data: dict) -> None:
|
i18n_data: dict,
|
||||||
|
sale_mode: str = "subscription") -> None:
|
||||||
try:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session, payment_db_id,
|
session, payment_db_id,
|
||||||
@@ -86,20 +88,24 @@ class StarsService:
|
|||||||
activation_details = await self.subscription_service.activate_subscription(
|
activation_details = await self.subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
months,
|
int(months) if sale_mode != "traffic" else 0,
|
||||||
float(stars_amount),
|
float(stars_amount),
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
provider="telegram_stars",
|
provider="telegram_stars",
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
traffic_gb=months if sale_mode == "traffic" else None,
|
||||||
)
|
)
|
||||||
if not activation_details or not activation_details.get("end_date"):
|
if not activation_details or not activation_details.get("end_date"):
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
referral_bonus = None
|
||||||
|
if sale_mode != "traffic":
|
||||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
months,
|
int(months) or 1,
|
||||||
current_payment_db_id=payment_db_id,
|
current_payment_db_id=payment_db_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
)
|
)
|
||||||
@@ -116,11 +122,18 @@ class StarsService:
|
|||||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||||
|
|
||||||
config_link = activation_details.get("subscription_url") or _(
|
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
||||||
"config_link_not_available"
|
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
||||||
)
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
|
||||||
if applied_days:
|
if sale_mode == "traffic":
|
||||||
|
success_msg = _(
|
||||||
|
"payment_successful_traffic_full",
|
||||||
|
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
||||||
|
end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
|
config_link=config_link_text,
|
||||||
|
)
|
||||||
|
elif applied_days:
|
||||||
inviter_name_display = _("friend_placeholder")
|
inviter_name_display = _("friend_placeholder")
|
||||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
@@ -138,17 +151,22 @@ class StarsService:
|
|||||||
bonus_days=applied_days,
|
bonus_days=applied_days,
|
||||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
inviter_name=inviter_name_display,
|
inviter_name=inviter_name_display,
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
success_msg = _(
|
success_msg = _(
|
||||||
"payment_successful_full",
|
"payment_successful_full",
|
||||||
months=months,
|
months=months,
|
||||||
end_date=final_end.strftime('%Y-%m-%d'),
|
end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link,
|
config_link=config_link_text,
|
||||||
)
|
)
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, self.settings, config_link, preserve_message=True
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
self.settings,
|
||||||
|
config_link_display,
|
||||||
|
connect_button_url=connect_button_url,
|
||||||
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
@@ -170,9 +188,10 @@ class StarsService:
|
|||||||
user_id=message.from_user.id,
|
user_id=message.from_user.id,
|
||||||
amount=float(stars_amount),
|
amount=float(stars_amount),
|
||||||
currency="XTR",
|
currency="XTR",
|
||||||
months=months,
|
months=int(months) if sale_mode != "traffic" else 0,
|
||||||
payment_provider="stars",
|
payment_provider="stars",
|
||||||
username=user.username if user else None
|
username=user.username if user else None,
|
||||||
|
traffic_gb=months if sale_mode == "traffic" else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send stars payment notification: {e}")
|
logging.error(f"Failed to send stars payment notification: {e}")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||||
from bot.utils.date_utils import add_months
|
from bot.utils.date_utils import add_months
|
||||||
|
from bot.utils.config_link import prepare_config_links
|
||||||
from db.models import User, Subscription
|
from db.models import User, Subscription
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -125,6 +126,7 @@ class SubscriptionService:
|
|||||||
(db_user.last_name or "") if db_user else "",
|
(db_user.last_name or "") if db_user else "",
|
||||||
]),
|
]),
|
||||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||||
|
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||||
)
|
)
|
||||||
@@ -153,6 +155,7 @@ class SubscriptionService:
|
|||||||
(db_user.last_name or "") if db_user else "",
|
(db_user.last_name or "") if db_user else "",
|
||||||
]),
|
]),
|
||||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||||
|
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||||
)
|
)
|
||||||
@@ -417,6 +420,119 @@ class SubscriptionService:
|
|||||||
"subscription_url": final_subscription_url,
|
"subscription_url": final_subscription_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _activate_traffic_package(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
traffic_gb: float,
|
||||||
|
payment_amount: float,
|
||||||
|
payment_db_id: int,
|
||||||
|
provider: str = "yookassa",
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Activate or extend a traffic-based package instead of a time-based subscription."""
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user:
|
||||||
|
logging.error("User %s not found for traffic package activation", user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = (
|
||||||
|
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not panel_user_uuid or not panel_sub_link_id:
|
||||||
|
logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
||||||
|
traffic_info = panel_user_data.get("userTraffic") or {}
|
||||||
|
current_limit = panel_user_data.get("trafficLimitBytes")
|
||||||
|
current_used = traffic_info.get("usedTrafficBytes")
|
||||||
|
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user_id, panel_user_uuid
|
||||||
|
)
|
||||||
|
if current_limit is None and active_sub:
|
||||||
|
current_limit = active_sub.traffic_limit_bytes
|
||||||
|
if current_used is None and active_sub:
|
||||||
|
current_used = active_sub.traffic_used_bytes
|
||||||
|
|
||||||
|
purchase_bytes = int(float(traffic_gb) * (1024**3))
|
||||||
|
new_limit = (current_limit or 0) + purchase_bytes
|
||||||
|
|
||||||
|
start_date = datetime.now(timezone.utc)
|
||||||
|
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further.
|
||||||
|
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||||
|
final_end_date = far_future
|
||||||
|
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
|
||||||
|
final_end_date = active_sub.end_date
|
||||||
|
|
||||||
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
|
session, panel_user_uuid, panel_sub_link_id
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"panel_user_uuid": panel_user_uuid,
|
||||||
|
"panel_subscription_uuid": panel_sub_link_id,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": final_end_date,
|
||||||
|
"duration_months": 0,
|
||||||
|
"is_active": True,
|
||||||
|
"status_from_panel": "ACTIVE",
|
||||||
|
"traffic_limit_bytes": new_limit,
|
||||||
|
"traffic_used_bytes": current_used,
|
||||||
|
"provider": provider,
|
||||||
|
"skip_notifications": True,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
|
panel_user_uuid=panel_user_uuid,
|
||||||
|
expire_at=final_end_date,
|
||||||
|
status="ACTIVE",
|
||||||
|
traffic_limit_bytes=new_limit,
|
||||||
|
traffic_limit_strategy="NO_RESET",
|
||||||
|
)
|
||||||
|
|
||||||
|
panel_update_payload["description"] = "\n".join(
|
||||||
|
[
|
||||||
|
(db_user.username or "") if db_user else "",
|
||||||
|
(db_user.first_name or "") if db_user else "",
|
||||||
|
(db_user.last_name or "") if db_user else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
|
panel_user_uuid, panel_update_payload
|
||||||
|
)
|
||||||
|
if not updated_panel_user or updated_panel_user.get("error"):
|
||||||
|
logging.warning(
|
||||||
|
"Panel user details update FAILED for traffic package user %s. Response: %s",
|
||||||
|
panel_user_uuid,
|
||||||
|
updated_panel_user,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||||
|
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subscription_id": new_or_updated_sub.subscription_id,
|
||||||
|
"end_date": final_end_date,
|
||||||
|
"is_active": True,
|
||||||
|
"panel_user_uuid": panel_user_uuid,
|
||||||
|
"panel_short_uuid": final_panel_short_uuid,
|
||||||
|
"subscription_url": final_subscription_url,
|
||||||
|
"applied_promo_bonus_days": 0,
|
||||||
|
"traffic_limit_bytes": new_limit,
|
||||||
|
}
|
||||||
|
|
||||||
async def activate_subscription(
|
async def activate_subscription(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -426,8 +542,21 @@ class SubscriptionService:
|
|||||||
payment_db_id: int,
|
payment_db_id: int,
|
||||||
promo_code_id_from_payment: Optional[int] = None,
|
promo_code_id_from_payment: Optional[int] = None,
|
||||||
provider: str = "yookassa",
|
provider: str = "yookassa",
|
||||||
|
sale_mode: str = "subscription",
|
||||||
|
traffic_gb: Optional[float] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
|
||||||
|
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
|
||||||
|
target_gb = traffic_gb if traffic_gb is not None else float(months)
|
||||||
|
return await self._activate_traffic_package(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
traffic_gb=target_gb,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
payment_db_id=payment_db_id,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -445,6 +574,11 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
months_int = int(months)
|
||||||
|
except Exception:
|
||||||
|
months_int = 1
|
||||||
|
|
||||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_user_uuid
|
session, user_id, panel_user_uuid
|
||||||
)
|
)
|
||||||
@@ -457,7 +591,7 @@ class SubscriptionService:
|
|||||||
start_date = current_active_sub.end_date
|
start_date = current_active_sub.end_date
|
||||||
|
|
||||||
# base duration by months
|
# base duration by months
|
||||||
end_after_months = add_months(start_date, months)
|
end_after_months = add_months(start_date, months_int)
|
||||||
duration_days_total = (end_after_months - start_date).days
|
duration_days_total = (end_after_months - start_date).days
|
||||||
applied_promo_bonus_days = 0
|
applied_promo_bonus_days = 0
|
||||||
|
|
||||||
@@ -498,19 +632,25 @@ class SubscriptionService:
|
|||||||
session, panel_user_uuid, panel_sub_link_id
|
session, panel_user_uuid, panel_sub_link_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auto_renew_should_enable = False
|
||||||
|
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
|
||||||
|
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||||
|
session, user_id
|
||||||
|
)
|
||||||
|
|
||||||
sub_payload = {
|
sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"panel_user_uuid": panel_user_uuid,
|
"panel_user_uuid": panel_user_uuid,
|
||||||
"panel_subscription_uuid": panel_sub_link_id,
|
"panel_subscription_uuid": panel_sub_link_id,
|
||||||
"start_date": start_date,
|
"start_date": start_date,
|
||||||
"end_date": final_end_date,
|
"end_date": final_end_date,
|
||||||
"duration_months": months,
|
"duration_months": months_int,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "ACTIVE",
|
"status_from_panel": "ACTIVE",
|
||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
"skip_notifications": False,
|
||||||
"auto_renew_enabled": True,
|
"auto_renew_enabled": auto_renew_should_enable,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||||
@@ -616,6 +756,7 @@ class SubscriptionService:
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": traffic_limit,
|
"traffic_limit_bytes": traffic_limit,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_uuid
|
session, panel_uuid, panel_sub_uuid
|
||||||
@@ -705,7 +846,8 @@ class SubscriptionService:
|
|||||||
update_payload_local = {}
|
update_payload_local = {}
|
||||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||||
|
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
|
||||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||||
"subscriptionUuid"
|
"subscriptionUuid"
|
||||||
@@ -759,6 +901,8 @@ class SubscriptionService:
|
|||||||
if panel_user_data.get("expireAt")
|
if panel_user_data.get("expireAt")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||||
|
display_link, connect_button_url = prepare_config_links(self.settings, config_link_raw)
|
||||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
if hwid_limit is None:
|
if hwid_limit is None:
|
||||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
@@ -767,9 +911,10 @@ class SubscriptionService:
|
|||||||
"user_id": panel_user_data.get("uuid"),
|
"user_id": panel_user_data.get("uuid"),
|
||||||
"end_date": panel_end_date,
|
"end_date": panel_end_date,
|
||||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
"config_link": display_link,
|
||||||
|
"connect_button_url": connect_button_url,
|
||||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
"max_devices": hwid_limit,
|
"max_devices": hwid_limit,
|
||||||
@@ -813,13 +958,16 @@ class SubscriptionService:
|
|||||||
sub: Subscription,
|
sub: Subscription,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||||
|
if getattr(self.settings, "traffic_sale_mode", False):
|
||||||
|
logging.info("Auto-renew skipped: traffic sale mode enabled")
|
||||||
|
return True
|
||||||
if not sub.auto_renew_enabled:
|
if not sub.auto_renew_enabled:
|
||||||
return True
|
return True
|
||||||
# If autopayments are disabled globally, skip charging attempts
|
# If autopayments are disabled globally, skip charging attempts
|
||||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if not self.settings.yookassa_autopayments_active:
|
||||||
return True
|
return True
|
||||||
if sub.provider == "tribute":
|
if sub.provider != "yookassa":
|
||||||
# Tribute is paid externally; we do not auto-charge here
|
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||||
@@ -892,6 +1040,7 @@ class SubscriptionService:
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
traffic_limit_bytes: Optional[int] = None,
|
traffic_limit_bytes: Optional[int] = None,
|
||||||
include_uuid: bool = True,
|
include_uuid: bool = True,
|
||||||
|
traffic_limit_strategy: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
payload: Dict[str, Any] = {}
|
payload: Dict[str, Any] = {}
|
||||||
if include_uuid and panel_user_uuid:
|
if include_uuid and panel_user_uuid:
|
||||||
@@ -902,7 +1051,9 @@ class SubscriptionService:
|
|||||||
payload["status"] = status
|
payload["status"] = status
|
||||||
if traffic_limit_bytes is not None:
|
if traffic_limit_bytes is not None:
|
||||||
payload["trafficLimitBytes"] = traffic_limit_bytes
|
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||||
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
|
||||||
if self.settings.parsed_user_squad_uuids:
|
if self.settings.parsed_user_squad_uuids:
|
||||||
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||||
|
if self.settings.parsed_user_external_squad_uuid:
|
||||||
|
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
import logging
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
from aiogram import Bot
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from config.settings import Settings
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
|
||||||
from bot.services.subscription_service import SubscriptionService
|
|
||||||
from bot.services.panel_api_service import PanelApiService
|
|
||||||
from bot.services.referral_service import ReferralService
|
|
||||||
from .notification_service import NotificationService
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
|
||||||
from db.dal import payment_dal, user_dal, subscription_dal
|
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
|
||||||
|
|
||||||
|
|
||||||
def convert_period_to_months(period: Optional[str]) -> int:
|
|
||||||
"""Map Tribute subscription period strings to months."""
|
|
||||||
if not period:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
mapping = {
|
|
||||||
"monthly": 1,
|
|
||||||
"quarterly": 3,
|
|
||||||
"3-month": 3,
|
|
||||||
"3months": 3,
|
|
||||||
"3-months": 3,
|
|
||||||
"q": 3,
|
|
||||||
"halfyearly": 6,
|
|
||||||
"yearly": 12,
|
|
||||||
"annual": 12,
|
|
||||||
"y": 12,
|
|
||||||
}
|
|
||||||
return mapping.get(period.lower(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
class TributeService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
bot: Bot,
|
|
||||||
settings: Settings,
|
|
||||||
i18n: JsonI18n,
|
|
||||||
async_session_factory: sessionmaker,
|
|
||||||
panel_service: PanelApiService,
|
|
||||||
subscription_service: SubscriptionService,
|
|
||||||
referral_service: ReferralService,
|
|
||||||
):
|
|
||||||
self.bot = bot
|
|
||||||
self.settings = settings
|
|
||||||
self.i18n = i18n
|
|
||||||
self.async_session_factory = async_session_factory
|
|
||||||
self.panel_service = panel_service
|
|
||||||
self.subscription_service = subscription_service
|
|
||||||
self.referral_service = referral_service
|
|
||||||
|
|
||||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
|
||||||
settings = self.settings
|
|
||||||
bot = self.bot
|
|
||||||
i18n = self.i18n
|
|
||||||
async_session_factory = self.async_session_factory
|
|
||||||
subscription_service = self.subscription_service
|
|
||||||
referral_service = self.referral_service
|
|
||||||
|
|
||||||
def ok(data: Optional[dict] = None) -> web.Response:
|
|
||||||
payload = {"status": "ok"}
|
|
||||||
if data:
|
|
||||||
payload.update(data)
|
|
||||||
return web.json_response(payload, status=200)
|
|
||||||
|
|
||||||
def ignored(reason: str) -> web.Response:
|
|
||||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
|
||||||
|
|
||||||
def bad_request(reason: str) -> web.Response:
|
|
||||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
|
||||||
|
|
||||||
if settings.TRIBUTE_API_KEY:
|
|
||||||
if not signature_header:
|
|
||||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
|
||||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
|
||||||
hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(expected_sig, signature_header):
|
|
||||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw_body.decode())
|
|
||||||
except Exception:
|
|
||||||
return bad_request("invalid_json")
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
"Tribute webhook data: %s",
|
|
||||||
json.dumps(payload, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tribute webhook spec: only two events are sent
|
|
||||||
# name: new_subscription | cancelled_subscription
|
|
||||||
event_name = payload.get("name")
|
|
||||||
data = payload.get("payload", {})
|
|
||||||
|
|
||||||
# Mandatory routing fields
|
|
||||||
user_id = data.get("telegram_user_id")
|
|
||||||
if not user_id:
|
|
||||||
# Permanent format issue — acknowledge to avoid retries
|
|
||||||
return ignored("missing_telegram_user_id")
|
|
||||||
|
|
||||||
period_val = data.get("period")
|
|
||||||
months = convert_period_to_months(period_val)
|
|
||||||
|
|
||||||
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
|
|
||||||
amount_value = data.get("amount") or data.get("price")
|
|
||||||
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
|
||||||
if amount_value is not None:
|
|
||||||
try:
|
|
||||||
amount_minor_units = float(amount_value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
amount_minor_units = 0.0
|
|
||||||
amount_float = round(amount_minor_units / 100.0, 2)
|
|
||||||
else:
|
|
||||||
amount_float = 0.0
|
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
|
||||||
if event_name == "new_subscription":
|
|
||||||
# Use a unique, idempotent provider payment id per webhook event
|
|
||||||
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
|
||||||
candidate_event_id = (
|
|
||||||
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
|
||||||
)
|
|
||||||
if candidate_event_id:
|
|
||||||
provider_payment_id = candidate_event_id
|
|
||||||
else:
|
|
||||||
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
|
||||||
sub_id_part = str(data.get("subscription_id") or "sub")
|
|
||||||
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
|
||||||
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
|
||||||
|
|
||||||
# Idempotent ensure payment
|
|
||||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
|
||||||
session,
|
|
||||||
user_id=int(user_id),
|
|
||||||
amount=amount_float,
|
|
||||||
currency=currency,
|
|
||||||
months=months,
|
|
||||||
description="Tribute subscription",
|
|
||||||
provider="tribute",
|
|
||||||
provider_payment_id=provider_payment_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
activation_details = await subscription_service.activate_subscription(
|
|
||||||
session,
|
|
||||||
int(user_id),
|
|
||||||
months,
|
|
||||||
float(amount_float),
|
|
||||||
payment_record.payment_id,
|
|
||||||
provider="tribute",
|
|
||||||
)
|
|
||||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
|
||||||
session,
|
|
||||||
int(user_id),
|
|
||||||
months,
|
|
||||||
current_payment_db_id=payment_record.payment_id,
|
|
||||||
skip_if_active_before_payment=False,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
|
||||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
|
||||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
|
||||||
|
|
||||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
|
||||||
final_end = (referral_bonus.get('referee_new_end_date')
|
|
||||||
if referral_bonus else None)
|
|
||||||
if not final_end:
|
|
||||||
final_end = activation_details.get('end_date')
|
|
||||||
|
|
||||||
if final_end:
|
|
||||||
config_link = activation_details.get("subscription_url") or _(
|
|
||||||
"config_link_not_available"
|
|
||||||
)
|
|
||||||
|
|
||||||
if applied_ref_days:
|
|
||||||
inviter_name_display = _('friend_placeholder')
|
|
||||||
if db_user and db_user.referred_by_id:
|
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
|
||||||
if inviter:
|
|
||||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
|
||||||
if safe_name:
|
|
||||||
inviter_name_display = safe_name
|
|
||||||
elif inviter.username:
|
|
||||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
|
||||||
success_msg = _(
|
|
||||||
"payment_successful_with_referral_bonus_full",
|
|
||||||
months=months,
|
|
||||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
|
||||||
bonus_days=applied_ref_days,
|
|
||||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
|
||||||
inviter_name=inviter_name_display,
|
|
||||||
config_link=config_link,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
success_msg = _(
|
|
||||||
"payment_successful_full",
|
|
||||||
months=months,
|
|
||||||
end_date=final_end.strftime('%Y-%m-%d'),
|
|
||||||
config_link=config_link,
|
|
||||||
)
|
|
||||||
markup = get_connect_and_main_keyboard(
|
|
||||||
lang,
|
|
||||||
i18n,
|
|
||||||
settings,
|
|
||||||
config_link,
|
|
||||||
preserve_message=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use user's DB language in success messages prepared above
|
|
||||||
await bot.send_message(
|
|
||||||
int(user_id),
|
|
||||||
success_msg,
|
|
||||||
reply_markup=markup,
|
|
||||||
parse_mode="HTML",
|
|
||||||
disable_web_page_preview=True,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
|
||||||
|
|
||||||
# Send notification about payment
|
|
||||||
try:
|
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
|
||||||
user = await user_dal.get_user_by_id(session, int(user_id))
|
|
||||||
await notification_service.notify_payment_received(
|
|
||||||
user_id=int(user_id),
|
|
||||||
amount=float(amount_float),
|
|
||||||
currency=currency,
|
|
||||||
months=months,
|
|
||||||
payment_provider="tribute",
|
|
||||||
username=user.username if user else None
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
|
||||||
elif event_name == "cancelled_subscription":
|
|
||||||
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
|
||||||
|
|
||||||
else:
|
|
||||||
await session.commit()
|
|
||||||
# Acknowledge to Tribute that webhook was received and processed/accepted
|
|
||||||
return ok({"event": event_name or "unknown"})
|
|
||||||
|
|
||||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
|
||||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from db.dal import subscription_dal, user_dal
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
|
||||||
|
|
||||||
try:
|
|
||||||
grace_days = 1
|
|
||||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
|
||||||
|
|
||||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
|
||||||
|
|
||||||
panel_users_updated: set[str] = set()
|
|
||||||
for sub in active_subscriptions:
|
|
||||||
updated_sub = await subscription_dal.update_subscription(
|
|
||||||
session,
|
|
||||||
sub.subscription_id,
|
|
||||||
{
|
|
||||||
"end_date": grace_end,
|
|
||||||
"status_from_panel": "CANCELLED",
|
|
||||||
"skip_notifications": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
|
||||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
|
||||||
panel_users_updated.add(panel_uuid)
|
|
||||||
panel_payload = {
|
|
||||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
await self.panel_service.update_user_details_on_panel(
|
|
||||||
panel_uuid,
|
|
||||||
panel_payload,
|
|
||||||
log_response=False,
|
|
||||||
)
|
|
||||||
except Exception as panel_err:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Send notification about cancellation if enabled
|
|
||||||
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
|
||||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
|
||||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
|
||||||
|
|
||||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
|
|
||||||
markup = get_subscribe_only_markup(lang, i18n)
|
|
||||||
|
|
||||||
cancellation_msg = _(
|
|
||||||
"tribute_subscription_cancelled",
|
|
||||||
default="🚨 <b>Подписка отменена</b>\n\n"
|
|
||||||
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
|
|
||||||
"после чего подписка будет заблокирована.\n\n"
|
|
||||||
"Для продления подписки нажмите кнопку ниже.",
|
|
||||||
user_name=first_name
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await bot.send_message(
|
|
||||||
int(user_id),
|
|
||||||
cancellation_msg,
|
|
||||||
reply_markup=markup,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
|
|
||||||
|
|
||||||
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
async def tribute_webhook_route(request: web.Request):
|
|
||||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
|
||||||
tribute_service: TributeService = request.app['tribute_service']
|
|
||||||
raw_body = await request.read()
|
|
||||||
signature_header = request.headers.get('trbt-signature')
|
|
||||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
|
||||||
@@ -21,7 +21,10 @@ class YooKassaService:
|
|||||||
|
|
||||||
self.settings = settings_obj
|
self.settings = settings_obj
|
||||||
|
|
||||||
if not shop_id or not secret_key:
|
if self.settings and not self.settings.YOOKASSA_ENABLED:
|
||||||
|
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
|
||||||
|
self.configured = False
|
||||||
|
elif not shop_id or not secret_key:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
||||||
"Payment functionality will be DISABLED.")
|
"Payment functionality will be DISABLED.")
|
||||||
@@ -111,6 +114,8 @@ class YooKassaService:
|
|||||||
capture = False
|
capture = False
|
||||||
amount = max(amount, 1.00)
|
amount = max(amount, 1.00)
|
||||||
builder.set_capture(capture)
|
builder.set_capture(capture)
|
||||||
|
if not payment_method_id:
|
||||||
|
# Saved payment_method_id charges must omit confirmation per YooKassa API
|
||||||
builder.set_confirmation({
|
builder.set_confirmation({
|
||||||
"type": ConfirmationType.REDIRECT,
|
"type": ConfirmationType.REDIRECT,
|
||||||
"return_url": self.return_url
|
"return_url": self.return_url
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_search = State()
|
waiting_for_user_search = State()
|
||||||
waiting_for_subscription_days_to_add = State()
|
waiting_for_subscription_days_to_add = State()
|
||||||
waiting_for_direct_message_to_user = State()
|
waiting_for_direct_message_to_user = State()
|
||||||
|
waiting_for_user_delete_confirmation = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
waiting_for_ad_source = State()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
CRYPT4_PREFIX = "happ://crypt4/"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _load_crypt4_public_key() -> Optional[RSAPublicKey]:
|
||||||
|
"""Load and cache the happ crypt4 public key from the project root."""
|
||||||
|
pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem"
|
||||||
|
try:
|
||||||
|
pem_bytes = pem_path.read_bytes()
|
||||||
|
key = serialization.load_pem_public_key(pem_bytes)
|
||||||
|
return key if isinstance(key, RSAPublicKey) else None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logging.error("Crypt4 public key file not found at %s", pem_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Failed to load crypt4 public key: %s", exc, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_raw_link(raw_link: str) -> Optional[str]:
|
||||||
|
"""Encrypt the raw subscription URL with RSA PKCS#1 v1.5 and return base64 payload."""
|
||||||
|
public_key = _load_crypt4_public_key()
|
||||||
|
if not public_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15())
|
||||||
|
return base64.b64encode(encrypted).decode("utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Build the user-facing connection key and the URL for the connect button.
|
||||||
|
|
||||||
|
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||||
|
is encrypted and prefixed with happ://crypt4/, and the button link is wrapped
|
||||||
|
with CRYPT4_REDIRECT_URL if provided.
|
||||||
|
"""
|
||||||
|
if not raw_link:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
cleaned = raw_link.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
display_link = cleaned
|
||||||
|
button_link = cleaned
|
||||||
|
|
||||||
|
if settings.CRYPT4_ENABLED:
|
||||||
|
encrypted_payload = _encrypt_raw_link(cleaned)
|
||||||
|
if encrypted_payload:
|
||||||
|
display_link = f"{CRYPT4_PREFIX}{encrypted_payload}"
|
||||||
|
button_link = display_link
|
||||||
|
else:
|
||||||
|
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
||||||
|
|
||||||
|
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
|
||||||
|
if redirect_base and settings.CRYPT4_ENABLED and display_link:
|
||||||
|
button_link = f"{redirect_base}{display_link}"
|
||||||
|
|
||||||
|
return display_link, button_link
|
||||||
@@ -5,6 +5,12 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
|
from bot.utils.telegram_markup import (
|
||||||
|
is_profile_link_error,
|
||||||
|
remove_profile_link_buttons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -51,13 +57,30 @@ class MessageQueue:
|
|||||||
message = self.queue.popleft()
|
message = self.queue.popleft()
|
||||||
try:
|
try:
|
||||||
await self._send_message(message)
|
await self._send_message(message)
|
||||||
self.last_send_times.append(datetime.now())
|
self._record_send_time()
|
||||||
self.total_sent += 1
|
|
||||||
|
|
||||||
# Keep only recent send times (last minute)
|
except TelegramBadRequest as exc:
|
||||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
fallback_message = self._build_profile_link_fallback(message, exc)
|
||||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
if fallback_message:
|
||||||
self.last_send_times.popleft()
|
logging.warning(
|
||||||
|
"Telegram rejected profile buttons for chat %s: %s. "
|
||||||
|
"Retrying without tg:// links.",
|
||||||
|
message.chat_id,
|
||||||
|
getattr(exc, "message", "") or str(exc),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._send_message(fallback_message)
|
||||||
|
self._record_send_time()
|
||||||
|
continue
|
||||||
|
except Exception as retry_exc:
|
||||||
|
self.total_failed += 1
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send fallback message to {message.chat_id}: {retry_exc}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.total_failed += 1
|
||||||
|
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.total_failed += 1
|
self.total_failed += 1
|
||||||
@@ -78,6 +101,38 @@ class MessageQueue:
|
|||||||
wait_time = self.delay_between_messages - time_since_last
|
wait_time = self.delay_between_messages - time_since_last
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
|
def _record_send_time(self) -> None:
|
||||||
|
"""Track sent message timestamps and purge old entries for rate limiting."""
|
||||||
|
now = datetime.now()
|
||||||
|
self.last_send_times.append(now)
|
||||||
|
self.total_sent += 1
|
||||||
|
|
||||||
|
cutoff_time = now - timedelta(seconds=60)
|
||||||
|
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||||
|
self.last_send_times.popleft()
|
||||||
|
|
||||||
|
def _build_profile_link_fallback(
|
||||||
|
self, message: QueuedMessage, exc: Exception
|
||||||
|
) -> Optional[QueuedMessage]:
|
||||||
|
"""Create a fallback message without tg://user buttons when Telegram rejects them."""
|
||||||
|
if not is_profile_link_error(exc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
markup = message.kwargs.get("reply_markup")
|
||||||
|
if markup is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
safe_markup = remove_profile_link_buttons(markup)
|
||||||
|
fallback_kwargs = dict(message.kwargs)
|
||||||
|
fallback_kwargs["reply_markup"] = safe_markup
|
||||||
|
|
||||||
|
return QueuedMessage(
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
method_name=message.method_name,
|
||||||
|
kwargs=fallback_kwargs,
|
||||||
|
callback=message.callback,
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||||
"""Send a single message - to be implemented by subclass"""
|
"""Send a single message - to be implemented by subclass"""
|
||||||
raise NotImplementedError("Subclass must implement _send_message")
|
raise NotImplementedError("Subclass must implement _send_message")
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import types
|
||||||
|
|
||||||
|
PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED")
|
||||||
|
TG_USER_LINK_PREFIX = "tg://user?id="
|
||||||
|
|
||||||
|
|
||||||
|
def remove_profile_link_buttons(
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup],
|
||||||
|
) -> Optional[types.InlineKeyboardMarkup]:
|
||||||
|
"""Remove buttons that point to tg://user links to avoid privacy-related errors."""
|
||||||
|
inline_keyboard = getattr(markup, "inline_keyboard", None)
|
||||||
|
if not markup or not inline_keyboard:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cleaned_rows = []
|
||||||
|
for row in inline_keyboard:
|
||||||
|
filtered_row = [
|
||||||
|
button
|
||||||
|
for button in row
|
||||||
|
if not (
|
||||||
|
getattr(button, "url", None)
|
||||||
|
and str(button.url).startswith(TG_USER_LINK_PREFIX)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if filtered_row:
|
||||||
|
cleaned_rows.append(filtered_row)
|
||||||
|
|
||||||
|
if not cleaned_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def is_profile_link_error(exc: BaseException) -> bool:
|
||||||
|
"""Return True if Telegram rejected markup because of profile link buttons."""
|
||||||
|
message = getattr(exc, "message", "") or str(exc)
|
||||||
|
return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)
|
||||||
+188
-37
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
|
|||||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||||
|
)
|
||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
@@ -49,6 +53,15 @@ class Settings(BaseSettings):
|
|||||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||||
|
PLATEGA_ENABLED: bool = Field(default=False)
|
||||||
|
PLATEGA_BASE_URL: str = Field(default="https://app.platega.io")
|
||||||
|
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||||
|
PLATEGA_SECRET: Optional[str] = None
|
||||||
|
PLATEGA_PAYMENT_METHOD: int = Field(
|
||||||
|
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
|
||||||
|
)
|
||||||
|
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
|
||||||
|
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
FREEKASSA_ENABLED: bool = Field(default=False)
|
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||||
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||||
@@ -59,9 +72,22 @@ class Settings(BaseSettings):
|
|||||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||||
|
|
||||||
|
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||||
|
SEVERPAY_MID: Optional[int] = None
|
||||||
|
SEVERPAY_TOKEN: Optional[str] = None
|
||||||
|
SEVERPAY_RETURN_URL: Optional[str] = None
|
||||||
|
SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||||
|
SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
|
||||||
|
)
|
||||||
|
|
||||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||||
STARS_ENABLED: bool = Field(default=True)
|
STARS_ENABLED: bool = Field(default=True)
|
||||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
|
||||||
|
)
|
||||||
|
|
||||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||||
@@ -77,17 +103,17 @@ class Settings(BaseSettings):
|
|||||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
|
||||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
|
||||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
|
||||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
|
||||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
|
||||||
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
|
|
||||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
|
|
||||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'",
|
||||||
|
)
|
||||||
|
STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'",
|
||||||
|
)
|
||||||
|
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||||
@@ -114,7 +140,11 @@ class Settings(BaseSettings):
|
|||||||
# Referral program configuration
|
# Referral program configuration
|
||||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
|
||||||
|
)
|
||||||
|
LEGACY_REFS: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||||
)
|
)
|
||||||
|
|
||||||
PANEL_API_URL: Optional[str] = None
|
PANEL_API_URL: Optional[str] = None
|
||||||
@@ -125,11 +155,18 @@ class Settings(BaseSettings):
|
|||||||
default=None,
|
default=None,
|
||||||
description=
|
description=
|
||||||
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
||||||
|
USER_EXTERNAL_SQUAD_UUID: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description=
|
||||||
|
"UUID of the external squad to assign to new panel users (optional)")
|
||||||
|
|
||||||
TRIAL_ENABLED: bool = Field(default=True)
|
TRIAL_ENABLED: bool = Field(default=True)
|
||||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||||
|
|
||||||
|
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||||
|
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||||
|
|
||||||
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
|
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||||
WEB_SERVER_PORT: int = Field(default=8080)
|
WEB_SERVER_PORT: int = Field(default=8080)
|
||||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||||
@@ -207,6 +244,15 @@ class Settings(BaseSettings):
|
|||||||
]
|
]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||||
|
if self.USER_EXTERNAL_SQUAD_UUID:
|
||||||
|
cleaned = self.USER_EXTERNAL_SQUAD_UUID.strip()
|
||||||
|
if cleaned:
|
||||||
|
return cleaned
|
||||||
|
return None
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def yookassa_webhook_path(self) -> str:
|
def yookassa_webhook_path(self) -> str:
|
||||||
@@ -221,19 +267,6 @@ class Settings(BaseSettings):
|
|||||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def tribute_webhook_path(self) -> str:
|
|
||||||
return "/webhook/tribute"
|
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
|
||||||
base = self.WEBHOOK_BASE_URL
|
|
||||||
if base:
|
|
||||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def panel_webhook_path(self) -> str:
|
def panel_webhook_path(self) -> str:
|
||||||
@@ -273,6 +306,32 @@ class Settings(BaseSettings):
|
|||||||
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def severpay_webhook_path(self) -> str:
|
||||||
|
return "/webhook/severpay"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def severpay_full_webhook_url(self) -> Optional[str]:
|
||||||
|
base = self.WEBHOOK_BASE_URL
|
||||||
|
if base:
|
||||||
|
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def platega_webhook_path(self) -> str:
|
||||||
|
return "/webhook/platega"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def platega_full_webhook_url(self) -> Optional[str]:
|
||||||
|
base = self.WEBHOOK_BASE_URL
|
||||||
|
if base:
|
||||||
|
return f"{base.rstrip('/')}{self.platega_webhook_path}"
|
||||||
|
return None
|
||||||
|
|
||||||
# Computed YooKassa receipt fields based on recurring toggle
|
# Computed YooKassa receipt fields based on recurring toggle
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@@ -317,17 +376,59 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def tribute_payment_links(self) -> Dict[int, str]:
|
def traffic_packages(self) -> Dict[float, float]:
|
||||||
links: Dict[int, str] = {}
|
"""
|
||||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
Mapping of traffic size in GB to price in the default currency.
|
||||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
"""
|
||||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
packages: Dict[float, float] = {}
|
||||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
raw = (self.TRAFFIC_PACKAGES or "").strip()
|
||||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
if not raw:
|
||||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
return packages
|
||||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
for part in raw.split(","):
|
||||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
chunk = part.strip()
|
||||||
return links
|
if not chunk or ":" not in chunk:
|
||||||
|
continue
|
||||||
|
size_str, price_str = chunk.split(":", 1)
|
||||||
|
try:
|
||||||
|
size_gb = float(size_str.strip())
|
||||||
|
price_val = float(price_str.strip())
|
||||||
|
if size_gb > 0 and price_val >= 0:
|
||||||
|
packages[size_gb] = price_val
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||||
|
continue
|
||||||
|
return packages
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def stars_traffic_packages(self) -> Dict[float, int]:
|
||||||
|
"""
|
||||||
|
Mapping of traffic size in GB to price in Telegram Stars.
|
||||||
|
"""
|
||||||
|
packages: Dict[float, int] = {}
|
||||||
|
raw = (self.STARS_TRAFFIC_PACKAGES or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return packages
|
||||||
|
for part in raw.split(","):
|
||||||
|
chunk = part.strip()
|
||||||
|
if not chunk or ":" not in chunk:
|
||||||
|
continue
|
||||||
|
size_str, price_str = chunk.split(":", 1)
|
||||||
|
try:
|
||||||
|
size_gb = float(size_str.strip())
|
||||||
|
price_val = int(float(price_str.strip()))
|
||||||
|
if size_gb > 0 and price_val >= 0:
|
||||||
|
packages[size_gb] = price_val
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||||
|
continue
|
||||||
|
return packages
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def traffic_sale_mode(self) -> bool:
|
||||||
|
"""When true, the bot sells traffic packages instead of time-based subscriptions."""
|
||||||
|
return bool(self.traffic_packages or self.stars_traffic_packages)
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@@ -357,6 +458,35 @@ class Settings(BaseSettings):
|
|||||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||||
return bonuses
|
return bonuses
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def yookassa_autopayments_active(self) -> bool:
|
||||||
|
"""Autopay features are available only when YooKassa itself is enabled."""
|
||||||
|
return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def payment_methods_order(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Ordered list of payment providers to show in the subscription payment keyboard.
|
||||||
|
"""
|
||||||
|
default_order = [
|
||||||
|
"freekassa",
|
||||||
|
"platega",
|
||||||
|
"severpay",
|
||||||
|
"yookassa",
|
||||||
|
"stars",
|
||||||
|
"cryptopay",
|
||||||
|
]
|
||||||
|
if not self.PAYMENT_METHODS_ORDER:
|
||||||
|
return default_order
|
||||||
|
methods = []
|
||||||
|
for item in self.PAYMENT_METHODS_ORDER.split(","):
|
||||||
|
slug = item.strip().lower()
|
||||||
|
if slug:
|
||||||
|
methods.append(slug)
|
||||||
|
return methods or default_order
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||||
@@ -369,14 +499,21 @@ class Settings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
@field_validator(
|
||||||
|
'REQUIRED_CHANNEL_LINK',
|
||||||
|
'PLATEGA_RETURN_URL',
|
||||||
|
'PLATEGA_FAILED_URL',
|
||||||
|
'SEVERPAY_RETURN_URL',
|
||||||
|
'CRYPT4_REDIRECT_URL',
|
||||||
|
mode='before',
|
||||||
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def sanitize_optional_link(cls, v):
|
def sanitize_optional_link(cls, v):
|
||||||
if isinstance(v, str) and not v.strip():
|
if isinstance(v, str) and not v.strip():
|
||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_optional_int(cls, v):
|
def validate_optional_int(cls, v):
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
@@ -436,6 +573,20 @@ def get_settings() -> Settings:
|
|||||||
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if _settings_instance.PLATEGA_ENABLED:
|
||||||
|
if (
|
||||||
|
not _settings_instance.PLATEGA_MERCHANT_ID
|
||||||
|
or not _settings_instance.PLATEGA_SECRET
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work."
|
||||||
|
)
|
||||||
|
if _settings_instance.SEVERPAY_ENABLED:
|
||||||
|
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work."
|
||||||
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logging.critical(
|
logging.critical(
|
||||||
f"Pydantic validation error while loading settings: {e}")
|
f"Pydantic validation error while loading settings: {e}")
|
||||||
|
|||||||
@@ -239,31 +239,6 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
|
|
||||||
"""Get duration in months from the last successful tribute payment for a user."""
|
|
||||||
stmt = select(Payment.subscription_duration_months).where(
|
|
||||||
and_(
|
|
||||||
Payment.user_id == user_id,
|
|
||||||
Payment.provider == 'tribute',
|
|
||||||
Payment.status == 'succeeded'
|
|
||||||
)
|
|
||||||
).order_by(Payment.created_at.desc()).limit(1)
|
|
||||||
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_last_tribute_payment(
|
|
||||||
session: AsyncSession, user_id: int) -> Optional[Payment]:
|
|
||||||
"""Return the most recent succeeded Tribute payment for the user."""
|
|
||||||
stmt = (select(Payment).where(
|
|
||||||
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
|
|
||||||
Payment.status == 'succeeded')).order_by(
|
|
||||||
Payment.created_at.desc()).limit(1))
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||||
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||||
stmt = select(func.sum(Payment.amount)).where(
|
stmt = select(func.sum(Payment.amount)).where(
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
|||||||
await session.delete(method)
|
await session.delete(method)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_saved_payment_method(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
provider: str = "yookassa",
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the user has at least one saved payment method."""
|
||||||
|
try:
|
||||||
|
methods = await list_user_payment_methods(session, user_id, provider)
|
||||||
|
if methods:
|
||||||
|
return True
|
||||||
|
billing = await get_user_billing(session, user_id)
|
||||||
|
return bool(billing and billing.yookassa_payment_method_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|||||||
+135
-2
@@ -1,13 +1,71 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy import update, delete, func, and_
|
from sqlalchemy import update, delete, func, and_, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from ..models import User, Subscription
|
from ..models import (
|
||||||
|
User,
|
||||||
|
Subscription,
|
||||||
|
Payment,
|
||||||
|
PromoCodeActivation,
|
||||||
|
MessageLog,
|
||||||
|
UserBilling,
|
||||||
|
UserPaymentMethod,
|
||||||
|
AdAttribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||||
|
REFERRAL_CODE_LENGTH = 9
|
||||||
|
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_referral_code_candidate() -> str:
|
||||||
|
return "".join(
|
||||||
|
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
|
||||||
|
stmt = select(User.user_id).where(User.referral_code == code)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique referral code consisting of uppercase alphanumeric characters.
|
||||||
|
Retries until a free code is found or raises RuntimeError after exceeding attempts.
|
||||||
|
"""
|
||||||
|
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
|
||||||
|
candidate = _generate_referral_code_candidate()
|
||||||
|
if not await _referral_code_exists(session, candidate):
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||||
|
"""
|
||||||
|
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||||
|
Returns the existing or newly generated code.
|
||||||
|
"""
|
||||||
|
if user.referral_code:
|
||||||
|
normalized = user.referral_code.strip().upper()
|
||||||
|
if normalized != user.referral_code:
|
||||||
|
user.referral_code = normalized
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user.referral_code
|
||||||
|
|
||||||
|
user.referral_code = await generate_unique_referral_code(session)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user.referral_code
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
@@ -43,6 +101,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
if "registration_date" not in user_data:
|
if "registration_date" not in user_data:
|
||||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if not user_data.get("referral_code"):
|
||||||
|
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||||
|
else:
|
||||||
|
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||||
|
|
||||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||||
stmt = (
|
stmt = (
|
||||||
pg_insert(User)
|
pg_insert(User)
|
||||||
@@ -71,6 +134,15 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
return user, created
|
return user, created
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||||
|
normalized = referral_code.strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
stmt = select(User).where(User.referral_code == normalized)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def update_user(
|
async def update_user(
|
||||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
@@ -102,6 +174,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_users_paginated(
|
||||||
|
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||||
|
) -> List[User]:
|
||||||
|
"""Return a slice of users ordered by newest registration first."""
|
||||||
|
safe_page = max(page, 0)
|
||||||
|
safe_page_size = max(page_size, 1)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.order_by(User.registration_date.desc())
|
||||||
|
.offset(safe_page * safe_page_size)
|
||||||
|
.limit(safe_page_size)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all_users(session: AsyncSession) -> int:
|
||||||
|
"""Count total number of users."""
|
||||||
|
result = await session.execute(select(func.count(User.user_id)))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||||
stmt = select(User.user_id).where(User.is_banned == False)
|
stmt = select(User.user_id).where(User.is_banned == False)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -227,3 +322,41 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""Completely remove a user and all dependent records from the database.
|
||||||
|
|
||||||
|
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||||
|
"""
|
||||||
|
user = await get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ensure referral pointers do not block deletion
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up dependent tables that do not cascade automatically
|
||||||
|
await session.execute(
|
||||||
|
delete(MessageLog).where(
|
||||||
|
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(Subscription).where(Subscription.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||||
|
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||||
|
|
||||||
|
await session.delete(user)
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -48,12 +48,86 @@ def _migration_0001_add_channel_subscription_fields(connection: Connection) -> N
|
|||||||
connection.execute(text(stmt))
|
connection.execute(text(stmt))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0002_add_referral_code(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
|
||||||
|
if "referral_code" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
WITH generated_codes AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
UPPER(
|
||||||
|
SUBSTRING(
|
||||||
|
md5(
|
||||||
|
user_id::text
|
||||||
|
|| clock_timestamp()::text
|
||||||
|
|| random()::text
|
||||||
|
)
|
||||||
|
FROM 1 FOR 9
|
||||||
|
)
|
||||||
|
) AS referral_code
|
||||||
|
FROM users
|
||||||
|
WHERE referral_code IS NULL OR referral_code = ''
|
||||||
|
)
|
||||||
|
UPDATE users AS u
|
||||||
|
SET referral_code = g.referral_code
|
||||||
|
FROM generated_codes AS g
|
||||||
|
WHERE u.user_id = g.user_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
|
||||||
|
ON users (referral_code)
|
||||||
|
WHERE referral_code IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
if "referral_code" not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET referral_code = UPPER(referral_code)
|
||||||
|
WHERE referral_code IS NOT NULL
|
||||||
|
AND referral_code <> UPPER(referral_code)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
description="Add columns to track required channel subscription verification",
|
description="Add columns to track required channel subscription verification",
|
||||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0002_add_referral_code",
|
||||||
|
description="Store short referral codes for users and backfill existing rows",
|
||||||
|
upgrade=_migration_0002_add_referral_code,
|
||||||
|
),
|
||||||
|
Migration(
|
||||||
|
id="0003_normalize_referral_codes",
|
||||||
|
description="Normalize referral codes to uppercase for consistent lookups",
|
||||||
|
upgrade=_migration_0003_normalize_referral_codes,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class User(Base):
|
|||||||
server_default=func.now())
|
server_default=func.now())
|
||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||||
|
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||||
referred_by_id = Column(BigInteger,
|
referred_by_id = Column(BigInteger,
|
||||||
ForeignKey("users.user_id"),
|
ForeignKey("users.user_id"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- '127.0.0.1:8080:8080'
|
||||||
networks:
|
networks:
|
||||||
- remnawave-network
|
- remnawave-network
|
||||||
environment:
|
environment:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3UZ0M3L4K+WjM3vkbQnz
|
||||||
|
ozHg/cRbEXvQ6i4A8RVN4OM3rK9kU01FdjyoIgywve8OEKsFnVwERZAQZ1Trv60B
|
||||||
|
hmaM76QQEE+EUlIOL9EpwKWGtTL5lYC1sT9XJMNP3/CI0gP5wwQI88cY/xedpOEB
|
||||||
|
W72EmOOShHUm/b/3m+HPmqwc4ugKj5zWV5SyiT829aFA5DxSjmIIFBAms7DafmSq
|
||||||
|
LFTYIQL5cShDY2u+/sqyAw9yZIOoqW2TFIgIHhLPWek/ocDU7zyOrlu1E0SmcQQb
|
||||||
|
LFqHq02fsnH6IcqTv3N5Adb/CkZDDQ6HvQVBmqbKZKf7ZdXkqsc/Zw27xhG7OfXC
|
||||||
|
tUmWsiL7zA+KoTd3avyOh93Q9ju4UQsHthL3Gs4vECYOCS9dsXXSHEY/1ngU/hjO
|
||||||
|
WFF8QEE/rYV6nA4PTyUvo5RsctSQL/9DJX7XNh3zngvif8LsCN2MPvx6X+zLouBX
|
||||||
|
zgBkQ9DFfZAGLWf9TR7KVjZC/3NsuUCDoAOcpmN8pENBbeB0puiKMMWSvll36+2M
|
||||||
|
YR1Xs0MgT8Y9TwhE2+TnnTJOhzmHi/BxiUlY/w2E0s4ax9GHAmX0wyF4zeV7kDkc
|
||||||
|
vHuEdc0d7vDmdw0oqCqWj0Xwq86HfORu6tm1A8uRATjb4SzjTKclKuoElVAVa5Jo
|
||||||
|
oh/uZMozC65SmDw+N5p6Su8CAwEAAQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+37
-8
@@ -25,8 +25,11 @@
|
|||||||
"error_displaying_menu": "Error displaying menu.",
|
"error_displaying_menu": "Error displaying menu.",
|
||||||
"main_menu_unknown_action": "Unknown action.",
|
"main_menu_unknown_action": "Unknown action.",
|
||||||
"select_subscription_period": "Select subscription period:",
|
"select_subscription_period": "Select subscription period:",
|
||||||
|
"select_traffic_package": "Select a traffic package:",
|
||||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||||
|
"buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}",
|
||||||
"choose_payment_method": "Choose payment method:",
|
"choose_payment_method": "Choose payment method:",
|
||||||
|
"choose_payment_method_traffic": "Choose how to pay for the traffic package:",
|
||||||
"pay_button": "💳 Pay",
|
"pay_button": "💳 Pay",
|
||||||
"pay_with_yookassa_button": "💳 YooKassa",
|
"pay_with_yookassa_button": "💳 YooKassa",
|
||||||
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||||
@@ -37,9 +40,10 @@
|
|||||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||||
"pay_with_sbp_button": "📱 SBP",
|
"pay_with_sbp_button": "📱 SBP",
|
||||||
|
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
|
||||||
|
"pay_with_severpay_button": "💳 SeverPay",
|
||||||
"back_to_payment_methods_button": "⬅️ Back",
|
"back_to_payment_methods_button": "⬅️ Back",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
|
||||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||||
"connect_button": "🔗 Connect",
|
"connect_button": "🔗 Connect",
|
||||||
"cancel_button": "❌ Cancel",
|
"cancel_button": "❌ Cancel",
|
||||||
@@ -55,11 +59,15 @@
|
|||||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||||
|
|
||||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||||
|
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||||
|
"payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:",
|
||||||
"free_kassa_order_info": "Order #{order_id} from {date}",
|
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||||
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||||
|
"payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.",
|
||||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
|
"payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||||
"config_link_not_available": "not available, contact support",
|
"config_link_not_available": "not available, contact support",
|
||||||
@@ -79,6 +87,7 @@
|
|||||||
"no_button": "No",
|
"no_button": "No",
|
||||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||||
|
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||||
"referral_share_message_button": "📩 Message for friend",
|
"referral_share_message_button": "📩 Message for friend",
|
||||||
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||||
"friend_placeholder": "friend",
|
"friend_placeholder": "friend",
|
||||||
@@ -101,7 +110,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Promos",
|
"admin_promo_marketing_section": "🎁 Promos",
|
||||||
"admin_system_functions_section": "⚙️ System",
|
"admin_system_functions_section": "⚙️ System",
|
||||||
"admin_ban_management_section": "🚫 Bans",
|
"admin_ban_management_section": "🚫 Bans",
|
||||||
"admin_users_management_button": "👤 Management",
|
"admin_users_search_button": "🔍 Search",
|
||||||
|
"admin_users_management_button": "👥 User List",
|
||||||
|
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||||
"back_to_user_management_button": "⬅️ To Users",
|
"back_to_user_management_button": "⬅️ To Users",
|
||||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||||
"admin_stats_header": "📊 Bot Statistics",
|
"admin_stats_header": "📊 Bot Statistics",
|
||||||
@@ -127,6 +139,7 @@
|
|||||||
"admin_csv_provider": "Provider",
|
"admin_csv_provider": "Provider",
|
||||||
"admin_csv_status": "Status",
|
"admin_csv_status": "Status",
|
||||||
"admin_csv_description": "Description",
|
"admin_csv_description": "Description",
|
||||||
|
"admin_csv_units": "Months/GB",
|
||||||
"admin_csv_months": "Months",
|
"admin_csv_months": "Months",
|
||||||
"admin_csv_created_at": "Created At",
|
"admin_csv_created_at": "Created At",
|
||||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||||
@@ -207,6 +220,8 @@
|
|||||||
"admin_user_card_title": "User Card",
|
"admin_user_card_title": "User Card",
|
||||||
"user_card_ban_button": "🚫 Ban",
|
"user_card_ban_button": "🚫 Ban",
|
||||||
"user_card_unban_button": "✅ Unban",
|
"user_card_unban_button": "✅ Unban",
|
||||||
|
"user_card_open_profile_button": "👤 Open profile",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||||
"admin_logs_menu_title": "Logs Menu:",
|
"admin_logs_menu_title": "Logs Menu:",
|
||||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||||
@@ -233,8 +248,6 @@
|
|||||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
|
||||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
|
||||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||||
"admin_user_subscription_info": "Subscription Information:",
|
"admin_user_subscription_info": "Subscription Information:",
|
||||||
@@ -244,6 +257,16 @@
|
|||||||
"admin_user_send_message_button": "✉️ Send Message",
|
"admin_user_send_message_button": "✉️ Send Message",
|
||||||
"admin_user_view_logs_button": "📜 User Actions",
|
"admin_user_view_logs_button": "📜 User Actions",
|
||||||
"admin_user_refresh_button": "🔄 Refresh",
|
"admin_user_refresh_button": "🔄 Refresh",
|
||||||
|
"admin_user_delete_button": "❌ Delete User",
|
||||||
|
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||||
|
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||||
|
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||||
|
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||||
"admin_user_search_new_button": "🔍 Find Another",
|
"admin_user_search_new_button": "🔍 Find Another",
|
||||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||||
@@ -278,9 +301,12 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||||
"log_referral_suffix": " (referral from {referrer_id})",
|
"log_referral_suffix": " (referral from {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Open profile",
|
||||||
|
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||||
|
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||||
@@ -400,7 +426,11 @@
|
|||||||
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
||||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||||
|
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
||||||
|
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
|
||||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||||
|
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
|
||||||
|
"traffic_no_expiry": "no limit",
|
||||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||||
@@ -423,9 +453,8 @@
|
|||||||
"payment_method_tx_history_title": "📜 Transactions history",
|
"payment_method_tx_history_title": "📜 Transactions history",
|
||||||
"payment_method_no_history": "No transactions history.",
|
"payment_method_no_history": "No transactions history.",
|
||||||
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
"traffic_purchase_title": "Traffic purchase {traffic_gb} GB",
|
||||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
|
||||||
"subscription_not_active": "You don't have an active subscription.",
|
"subscription_not_active": "You don't have an active subscription.",
|
||||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||||
|
|||||||
+38
-9
@@ -9,7 +9,7 @@
|
|||||||
"menu_activate_trial_button": "🆓 Пробный период",
|
"menu_activate_trial_button": "🆓 Пробный период",
|
||||||
"menu_subscribe_inline": "🚀 Купить",
|
"menu_subscribe_inline": "🚀 Купить",
|
||||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||||
"menu_referral_inline": "🎁 Рефералы",
|
"menu_referral_inline": "🎁 Рефералы",
|
||||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||||
"menu_apply_promo_button": "🎟 Промокод",
|
"menu_apply_promo_button": "🎟 Промокод",
|
||||||
@@ -25,8 +25,11 @@
|
|||||||
"error_displaying_menu": "Ошибка отображения меню.",
|
"error_displaying_menu": "Ошибка отображения меню.",
|
||||||
"main_menu_unknown_action": "Неизвестное действие.",
|
"main_menu_unknown_action": "Неизвестное действие.",
|
||||||
"select_subscription_period": "Выберите срок подписки:",
|
"select_subscription_period": "Выберите срок подписки:",
|
||||||
|
"select_traffic_package": "Выберите пакет трафика:",
|
||||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||||
|
"buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}",
|
||||||
"choose_payment_method": "Выберите способ оплаты:",
|
"choose_payment_method": "Выберите способ оплаты:",
|
||||||
|
"choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:",
|
||||||
"pay_button": "💳 Оплатить",
|
"pay_button": "💳 Оплатить",
|
||||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||||
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||||
@@ -37,9 +40,10 @@
|
|||||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||||
"pay_with_sbp_button": "📱 СБП",
|
"pay_with_sbp_button": "📱 СБП",
|
||||||
|
"pay_with_platega_button": "💳 Platega (СБП/карты)",
|
||||||
|
"pay_with_severpay_button": "💳 SeverPay",
|
||||||
"back_to_payment_methods_button": "⬅️ Назад",
|
"back_to_payment_methods_button": "⬅️ Назад",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
|
||||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
|
|
||||||
@@ -55,11 +59,15 @@
|
|||||||
|
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
|
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||||
|
"payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:",
|
||||||
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||||
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||||
|
"payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
|
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||||
@@ -79,6 +87,7 @@
|
|||||||
"no_button": "Нет",
|
"no_button": "Нет",
|
||||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||||
|
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||||
"referral_share_message_button": "📩 Сообщение для друга",
|
"referral_share_message_button": "📩 Сообщение для друга",
|
||||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||||
"friend_placeholder": "друг",
|
"friend_placeholder": "друг",
|
||||||
@@ -101,7 +110,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||||
"admin_system_functions_section": "⚙️ Система",
|
"admin_system_functions_section": "⚙️ Система",
|
||||||
"admin_ban_management_section": "🚫 Блокировки",
|
"admin_ban_management_section": "🚫 Блокировки",
|
||||||
"admin_users_management_button": "👤 Управление",
|
"admin_users_search_button": "🔍 Поиск",
|
||||||
|
"admin_users_management_button": "👥 Список пользователей",
|
||||||
|
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||||
"back_to_user_management_button": "⬅️ К пользователям",
|
"back_to_user_management_button": "⬅️ К пользователям",
|
||||||
"back_to_admin_panel_button": "⬅️ В админку",
|
"back_to_admin_panel_button": "⬅️ В админку",
|
||||||
"admin_stats_header": "📊 Статистика Бота",
|
"admin_stats_header": "📊 Статистика Бота",
|
||||||
@@ -127,6 +139,7 @@
|
|||||||
"admin_csv_provider": "Платежная система",
|
"admin_csv_provider": "Платежная система",
|
||||||
"admin_csv_status": "Статус",
|
"admin_csv_status": "Статус",
|
||||||
"admin_csv_description": "Описание",
|
"admin_csv_description": "Описание",
|
||||||
|
"admin_csv_units": "Месяцы/ГБ",
|
||||||
"admin_csv_months": "Месяцев",
|
"admin_csv_months": "Месяцев",
|
||||||
"admin_csv_created_at": "Дата создания",
|
"admin_csv_created_at": "Дата создания",
|
||||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||||
@@ -164,7 +177,6 @@
|
|||||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
|
||||||
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||||
"admin_back_to_panel": "⬅️ В панель",
|
"admin_back_to_panel": "⬅️ В панель",
|
||||||
@@ -217,6 +229,8 @@
|
|||||||
"admin_user_card_title": "Карточка пользователя",
|
"admin_user_card_title": "Карточка пользователя",
|
||||||
"user_card_ban_button": "🚫 Заблокировать",
|
"user_card_ban_button": "🚫 Заблокировать",
|
||||||
"user_card_unban_button": "✅ Разблокировать",
|
"user_card_unban_button": "✅ Разблокировать",
|
||||||
|
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||||
"admin_logs_menu_title": "Меню логов:",
|
"admin_logs_menu_title": "Меню логов:",
|
||||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||||
@@ -235,7 +249,6 @@
|
|||||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||||
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
|
||||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||||
"admin_user_subscription_info": "Информация о подписке:",
|
"admin_user_subscription_info": "Информация о подписке:",
|
||||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||||
@@ -244,6 +257,16 @@
|
|||||||
"admin_user_send_message_button": "✉️ Сообщение",
|
"admin_user_send_message_button": "✉️ Сообщение",
|
||||||
"admin_user_view_logs_button": "📜 Логи",
|
"admin_user_view_logs_button": "📜 Логи",
|
||||||
"admin_user_refresh_button": "🔄 Обновить",
|
"admin_user_refresh_button": "🔄 Обновить",
|
||||||
|
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||||
|
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||||
|
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||||
|
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||||
|
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||||
@@ -278,9 +301,12 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Открыть профиль",
|
||||||
|
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||||
|
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||||
@@ -400,7 +426,11 @@
|
|||||||
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
||||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||||
|
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
||||||
|
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
|
||||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||||
|
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
|
||||||
|
"traffic_no_expiry": "без ограничения",
|
||||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||||
@@ -423,9 +453,8 @@
|
|||||||
"payment_method_tx_history_title": "📜 История операций",
|
"payment_method_tx_history_title": "📜 История операций",
|
||||||
"payment_method_no_history": "История операций отсутствует.",
|
"payment_method_no_history": "История операций отсутствует.",
|
||||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
"traffic_purchase_title": "Покупка {traffic_gb} ГБ",
|
||||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
|
||||||
"subscription_not_active": "У вас нет активной подписки.",
|
"subscription_not_active": "У вас нет активной подписки.",
|
||||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ sqlalchemy[asyncio]==2.0.29
|
|||||||
asyncpg==0.29.0
|
asyncpg==0.29.0
|
||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
aiocryptopay==0.4.8
|
aiocryptopay==0.4.8
|
||||||
|
cryptography==42.0.8
|
||||||
|
|||||||
Reference in New Issue
Block a user