Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bf8720a3 | ||
|
|
280aced20e | ||
|
|
d672032201 | ||
|
|
e826c4309d | ||
|
|
9f525dc7da | ||
|
|
b880032b9b | ||
|
|
8f9484b6ec | ||
|
|
c3622d9c2b | ||
|
|
51ffbbfa1d | ||
|
|
394e8dcc6c | ||
|
|
5f3fc13c2b | ||
|
|
4d43c9cf0f | ||
|
|
c891122064 | ||
|
|
59d07314e2 | ||
|
|
c438672ced | ||
|
|
49c532e4db | ||
|
|
d50324d098 | ||
|
|
52893a0629 | ||
|
|
664cea447c | ||
|
|
5f50cfeaee | ||
|
|
bdade47758 | ||
|
|
a3406451cb | ||
|
|
9938671af9 |
+19
-6
@@ -20,10 +20,23 @@ TERMS_OF_SERVICE_URL=https://example.com/tos #
|
|||||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||||
|
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||||
|
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||||
|
|
||||||
|
# Required channel subscription
|
||||||
|
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||||
|
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||||
|
|
||||||
# Webhook Base URL (used for Telegram and payment providers)
|
# Webhook Base URL (used for Telegram and payment providers)
|
||||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||||
|
|
||||||
|
# Payment Method Toggles
|
||||||
|
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||||
|
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||||
|
STARS_ENABLED=True # Turn on STARS
|
||||||
|
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||||
|
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||||
|
|
||||||
# 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
|
||||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||||
@@ -32,6 +45,12 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com #
|
|||||||
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
|
||||||
|
|
||||||
|
# FreeKassa Payment Gateway Configuration
|
||||||
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
|
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||||
|
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||||
|
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||||
|
|
||||||
# CryptoBot Payment Gateway Configuration
|
# CryptoBot Payment Gateway Configuration
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||||
@@ -43,12 +62,6 @@ TRIBUTE_API_KEY= #
|
|||||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||||
|
|
||||||
# Payment Method Toggles
|
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
|
||||||
STARS_ENABLED=True # Turn on STARS
|
|
||||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
|
||||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
|
||||||
|
|
||||||
# Subscription Options. Specify cost parameters or payment links here.
|
# Subscription Options. Specify cost parameters or payment links here.
|
||||||
1_MONTH_ENABLED=True
|
1_MONTH_ENABLED=True
|
||||||
RUB_PRICE_1_MONTH=150
|
RUB_PRICE_1_MONTH=150
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
### Для пользователей:
|
### Для пользователей:
|
||||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||||
|
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||||
|
|
||||||
### Для администраторов:
|
### Для администраторов:
|
||||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||||
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
|
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@
|
|||||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||||
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -81,6 +85,12 @@
|
|||||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
|
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||||
|
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||||
|
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||||
|
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||||
|
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||||
</details>
|
</details>
|
||||||
@@ -106,6 +116,9 @@
|
|||||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||||
|
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||||
|
|
||||||
|
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||||
</gidetails>
|
</gidetails>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -125,12 +138,13 @@
|
|||||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||||
|
|
||||||
4. **Настройка вебхуков (Обязательно):**
|
4. **Настройка вебхуков (Обязательно):**
|
||||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
|
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели 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/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/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`
|
||||||
@@ -143,6 +157,8 @@
|
|||||||
docker compose logs -f remnawave-tg-shop
|
docker compose logs -f remnawave-tg-shop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|
||||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
|||||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||||
|
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||||
|
|
||||||
|
|
||||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||||
@@ -31,8 +32,8 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
|||||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||||
|
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||||
|
|
||||||
return dp, bot, {"i18n_instance": i18n_instance}
|
return dp, bot, {"i18n_instance": i18n_instance}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService
|
|||||||
from bot.services.tribute_service import TributeService
|
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
|
||||||
|
|
||||||
|
|
||||||
def build_core_services(
|
def build_core_services(
|
||||||
@@ -36,6 +37,14 @@ def build_core_services(
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
referral_service,
|
referral_service,
|
||||||
)
|
)
|
||||||
|
freekassa_service = FreeKassaService(
|
||||||
|
bot=bot,
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
async_session_factory=async_session_factory,
|
||||||
|
subscription_service=subscription_service,
|
||||||
|
referral_service=referral_service,
|
||||||
|
)
|
||||||
tribute_service = TributeService(
|
tribute_service = TributeService(
|
||||||
bot,
|
bot,
|
||||||
settings,
|
settings,
|
||||||
@@ -70,9 +79,9 @@ def build_core_services(
|
|||||||
"promo_code_service": promo_code_service,
|
"promo_code_service": promo_code_service,
|
||||||
"stars_service": stars_service,
|
"stars_service": stars_service,
|
||||||
"cryptopay_service": cryptopay_service,
|
"cryptopay_service": cryptopay_service,
|
||||||
|
"freekassa_service": freekassa_service,
|
||||||
"tribute_service": tribute_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async def build_and_start_web_app(
|
|||||||
"referral_service",
|
"referral_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
"stars_service",
|
"stars_service",
|
||||||
|
"freekassa_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
"tribute_service",
|
"tribute_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
@@ -50,6 +51,7 @@ async def build_and_start_web_app(
|
|||||||
from bot.services.tribute_service import tribute_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
|
||||||
|
|
||||||
tribute_path = settings.tribute_webhook_path
|
tribute_path = settings.tribute_webhook_path
|
||||||
if tribute_path.startswith("/"):
|
if tribute_path.startswith("/"):
|
||||||
@@ -61,6 +63,11 @@ async def build_and_start_web_app(
|
|||||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||||
|
|
||||||
|
fk_path = settings.freekassa_webhook_path
|
||||||
|
if fk_path.startswith("/"):
|
||||||
|
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||||
|
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_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("/"):
|
||||||
@@ -88,4 +95,3 @@ async def build_and_start_web_app(
|
|||||||
# Run until cancelled
|
# Run until cancelled
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -334,11 +334,18 @@ async def confirm_broadcast_callback_handler(
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||||
|
|
||||||
# Get queue stats for detailed report
|
# Prepare queue stats presentation
|
||||||
queue_stats = queue_manager.get_queue_stats()
|
queue_stats = queue_manager.get_queue_stats()
|
||||||
|
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||||
result_message = (
|
initial_user_failed = queue_stats.get("user_failed_messages", 0)
|
||||||
_(
|
initial_group_failed = queue_stats.get("group_failed_messages", 0)
|
||||||
|
|
||||||
|
def build_queue_status(stats: dict) -> str:
|
||||||
|
dynamic_failed = max(
|
||||||
|
0, stats.get("user_failed_messages", 0) - initial_user_failed
|
||||||
|
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
|
||||||
|
total_failed = failed_count + dynamic_failed
|
||||||
|
return _(
|
||||||
"broadcast_queue_result",
|
"broadcast_queue_result",
|
||||||
default=(
|
default=(
|
||||||
"🚀 Рассылка поставлена в очередь!\n"
|
"🚀 Рассылка поставлена в очередь!\n"
|
||||||
@@ -350,16 +357,64 @@ async def confirm_broadcast_callback_handler(
|
|||||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||||
),
|
),
|
||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=failed_count,
|
failed_count=total_failed,
|
||||||
user_queue_size=queue_stats["user_queue_size"],
|
user_queue_size=stats["user_queue_size"],
|
||||||
group_queue_size=queue_stats["group_queue_size"],
|
group_queue_size=stats["group_queue_size"],
|
||||||
)
|
)
|
||||||
)
|
|
||||||
await callback.message.answer(
|
result_message = build_queue_status(queue_stats)
|
||||||
|
|
||||||
|
status_message = await callback.message.answer(
|
||||||
result_message,
|
result_message,
|
||||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
reply_markup=back_keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def auto_update_queue_status() -> None:
|
||||||
|
"""Refresh queue stats message twice per second via message edit."""
|
||||||
|
last_text = result_message
|
||||||
|
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
|
||||||
|
max_iterations = 240
|
||||||
|
for _ in range(max_iterations):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
stats = queue_manager.get_queue_stats()
|
||||||
|
new_text = build_queue_status(stats)
|
||||||
|
queues_drained = (
|
||||||
|
stats["user_queue_size"] == 0
|
||||||
|
and stats["group_queue_size"] == 0
|
||||||
|
and not stats.get("user_queue_processing")
|
||||||
|
and not stats.get("group_queue_processing")
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_text != last_text:
|
||||||
|
try:
|
||||||
|
await status_message.edit_text(
|
||||||
|
new_text,
|
||||||
|
reply_markup=back_keyboard,
|
||||||
|
)
|
||||||
|
last_text = new_text
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" in str(e):
|
||||||
|
last_text = new_text
|
||||||
|
else:
|
||||||
|
logging.debug(
|
||||||
|
"Broadcast queue auto-update stopped: %s", e
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logging.debug(
|
||||||
|
"Broadcast queue auto-update unexpected error: %s", e
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if queues_drained:
|
||||||
|
# Final refresh already attempted; exit loop.
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logging.debug("Broadcast queue auto-update reached time limit.")
|
||||||
|
|
||||||
|
asyncio.create_task(auto_update_queue_status())
|
||||||
|
|
||||||
elif action == "cancel":
|
elif action == "cancel":
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("admin_broadcast_cancelled"),
|
_("admin_broadcast_cancelled"),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||||
"⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌"
|
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_info = f"User {payment.user_id}"
|
user_info = f"User {payment.user_id}"
|
||||||
@@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
|||||||
'yookassa': 'YooKassa',
|
'yookassa': 'YooKassa',
|
||||||
'tribute': 'Tribute',
|
'tribute': 'Tribute',
|
||||||
'telegram_stars': 'Telegram Stars',
|
'telegram_stars': 'Telegram Stars',
|
||||||
'cryptopay': 'CryptoPay'
|
'cryptopay': 'CryptoPay',
|
||||||
|
'freekassa': 'FreeKassa',
|
||||||
}.get(payment.provider, payment.provider or 'Unknown')
|
}.get(payment.provider, payment.provider or 'Unknown')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
@router.callback_query(F.data == "noop")
|
@router.callback_query(F.data == "noop")
|
||||||
async def noop_handler(callback: types.CallbackQuery):
|
async def noop_handler(callback: types.CallbackQuery):
|
||||||
"""Handle no-op callback (for pagination display)."""
|
"""Handle no-op callback (for pagination display)."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
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 (
|
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||||
"⏳" if payment.status == 'pending'
|
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||||
or payment.status == 'pending_yookassa' 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:
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ 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
|
user_lang, i18n, settings, config_link, preserve_message=True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
|
|||||||
+263
-12
@@ -7,10 +7,16 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||||
|
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
from db.models import User
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_main_menu_inline_keyboard,
|
||||||
|
get_language_selection_keyboard,
|
||||||
|
get_channel_subscription_keyboard,
|
||||||
|
)
|
||||||
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.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
@@ -51,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
show_trial_button_in_menu = False
|
show_trial_button_in_menu = False
|
||||||
@@ -116,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message,
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_required_channel_subscription(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
current_lang: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
db_user: Optional[User] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Verify that the user is a member of the required channel (if configured).
|
||||||
|
Returns True when access can proceed, False when user must subscribe first.
|
||||||
|
"""
|
||||||
|
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||||
|
if not required_channel_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
user_id = event.from_user.id
|
||||||
|
bot_instance: Optional[Bot] = getattr(event, "bot", None)
|
||||||
|
if bot_instance is None and event.message:
|
||||||
|
bot_instance = event.message.bot
|
||||||
|
message_obj: Optional[types.Message] = event.message
|
||||||
|
else:
|
||||||
|
user_id = event.from_user.id
|
||||||
|
bot_instance = event.bot if hasattr(event, "bot") else None
|
||||||
|
message_obj = event
|
||||||
|
|
||||||
|
if bot_instance is None:
|
||||||
|
logging.error(
|
||||||
|
"Channel subscription check: bot instance missing for user %s.", user_id
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_id in settings.ADMIN_IDS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if db_user is None:
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
except Exception as fetch_error:
|
||||||
|
logging.error(
|
||||||
|
"Channel subscription check: failed to fetch user %s: %s",
|
||||||
|
user_id,
|
||||||
|
fetch_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
logging.warning(
|
||||||
|
"Required channel check skipped because user %s is not persisted yet.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (db_user.channel_subscription_verified
|
||||||
|
and db_user.channel_subscription_verified_for
|
||||||
|
== required_channel_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def translate(key: str, **kwargs) -> str:
|
||||||
|
if i18n:
|
||||||
|
return i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
return key
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
is_member = False
|
||||||
|
status_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
member = await bot_instance.get_chat_member(required_channel_id, user_id)
|
||||||
|
status = getattr(member, "status", None)
|
||||||
|
status_value = getattr(status, "value", status)
|
||||||
|
allowed_statuses = {"creator", "administrator", "member", "restricted"}
|
||||||
|
if status_value in allowed_statuses:
|
||||||
|
is_member = True
|
||||||
|
except TelegramBadRequest as bad_request:
|
||||||
|
logging.info(
|
||||||
|
"Required channel check: user %s not subscribed (details: %s)",
|
||||||
|
user_id,
|
||||||
|
bad_request,
|
||||||
|
)
|
||||||
|
except TelegramForbiddenError as forbidden_error:
|
||||||
|
logging.error(
|
||||||
|
"Required channel check failed due to insufficient permissions: %s",
|
||||||
|
forbidden_error,
|
||||||
|
)
|
||||||
|
error_text = translate("channel_subscription_check_failed")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(error_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(error_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(error_text)
|
||||||
|
return False
|
||||||
|
except TelegramAPIError as api_error:
|
||||||
|
logging.error(
|
||||||
|
"Required channel check failed for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
api_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
error_text = translate("channel_subscription_check_failed")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(error_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(error_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(error_text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
update_payload = {
|
||||||
|
"channel_subscription_checked_at": now,
|
||||||
|
"channel_subscription_verified_for": required_channel_id,
|
||||||
|
"channel_subscription_verified": is_member,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await user_dal.update_user(session, user_id, update_payload)
|
||||||
|
except Exception as update_error:
|
||||||
|
logging.error(
|
||||||
|
"Failed to persist channel verification result for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
update_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_member:
|
||||||
|
logging.info(
|
||||||
|
"User %s confirmed as member of required channel %s (status=%s).",
|
||||||
|
user_id,
|
||||||
|
required_channel_id,
|
||||||
|
status_value,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
keyboard = (get_channel_subscription_keyboard(
|
||||||
|
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
|
||||||
|
)
|
||||||
|
if i18n else None)
|
||||||
|
|
||||||
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
if keyboard and event.message:
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(prompt_text, reply_markup=keyboard)
|
||||||
|
except Exception as edit_error:
|
||||||
|
logging.debug(
|
||||||
|
"Failed to edit prompt message for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
edit_error,
|
||||||
|
)
|
||||||
|
if keyboard is None and message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(prompt_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.answer(prompt_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@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_(\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")))
|
||||||
@@ -243,55 +428,60 @@ async def start_command_handler(message: types.Message,
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||||
|
current_lang, session,
|
||||||
|
db_user):
|
||||||
|
return
|
||||||
|
|
||||||
# Send welcome message if not disabled
|
# Send welcome message if not disabled
|
||||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||||
|
|
||||||
# Auto-apply promo code if provided via start parameter
|
# Auto-apply promo code if provided via start parameter
|
||||||
if promo_code_to_apply:
|
if promo_code_to_apply:
|
||||||
try:
|
try:
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||||
|
|
||||||
success, result = await promo_code_service.apply_promo_code(
|
success, result = await promo_code_service.apply_promo_code(
|
||||||
session, user_id, promo_code_to_apply, current_lang
|
session, user_id, promo_code_to_apply, current_lang
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||||
|
|
||||||
# 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 = active.get("config_link") if active else None
|
||||||
config_link = config_link or _("config_link_not_available")
|
config_link = config_link 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
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),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't show main menu if promo was successfully applied
|
# Don't show main menu if promo was successfully applied
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||||
# Continue to show main menu if promo failed
|
# Continue to show main menu if promo failed
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
await send_main_menu(message,
|
await send_main_menu(message,
|
||||||
settings,
|
settings,
|
||||||
i18n_data,
|
i18n_data,
|
||||||
@@ -300,6 +490,57 @@ async def start_command_handler(message: types.Message,
|
|||||||
is_edit=False)
|
is_edit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "channel_subscription:verify")
|
||||||
|
async def verify_channel_subscription_callback(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
|
||||||
|
|
||||||
|
verified = await ensure_required_channel_subscription(
|
||||||
|
callback, settings, i18n, current_lang, session, db_user)
|
||||||
|
if not verified:
|
||||||
|
return
|
||||||
|
|
||||||
|
if db_user and db_user.language_code:
|
||||||
|
current_lang = db_user.language_code
|
||||||
|
i18n_data["current_language"] = current_lang
|
||||||
|
|
||||||
|
if i18n:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
else:
|
||||||
|
_ = lambda key, **kwargs: key
|
||||||
|
|
||||||
|
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||||
|
welcome_text = _(key="welcome",
|
||||||
|
user_name=hd.quote(callback.from_user.full_name))
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.answer(welcome_text)
|
||||||
|
else:
|
||||||
|
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
|
||||||
|
if fallback_bot:
|
||||||
|
await fallback_bot.send_message(callback.from_user.id,
|
||||||
|
welcome_text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||||
|
show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await send_main_menu(callback,
|
||||||
|
settings,
|
||||||
|
i18n_data,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
is_edit=bool(callback.message))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("language"))
|
@router.message(Command("language"))
|
||||||
@router.callback_query(F.data == "main_action:language")
|
@router.callback_query(F.data == "main_action:language")
|
||||||
async def language_command_handler(
|
async def language_command_handler(
|
||||||
@@ -404,10 +645,13 @@ async def main_action_callback_handler(
|
|||||||
await user_subscription_handlers.display_subscription_options(
|
await user_subscription_handlers.display_subscription_options(
|
||||||
callback, i18n_data, settings, session)
|
callback, i18n_data, settings, session)
|
||||||
elif action == "my_subscription":
|
elif action == "my_subscription":
|
||||||
|
|
||||||
await user_subscription_handlers.my_subscription_command_handler(
|
await user_subscription_handlers.my_subscription_command_handler(
|
||||||
callback, i18n_data, settings, panel_service, subscription_service,
|
callback, i18n_data, settings, panel_service, subscription_service,
|
||||||
session, bot)
|
session, bot)
|
||||||
|
elif action == "my_devices":
|
||||||
|
await user_subscription_handlers.my_devices_command_handler(
|
||||||
|
callback, i18n_data, settings, panel_service, subscription_service,
|
||||||
|
session, bot)
|
||||||
elif action == "referral":
|
elif action == "referral":
|
||||||
await user_referral_handlers.referral_command_handler(
|
await user_referral_handlers.referral_command_handler(
|
||||||
callback, settings, i18n_data, referral_service, bot, session)
|
callback, settings, i18n_data, referral_service, bot, session)
|
||||||
@@ -427,6 +671,13 @@ async def main_action_callback_handler(
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
session,
|
session,
|
||||||
is_edit=True)
|
is_edit=True)
|
||||||
|
elif action == "back_to_main_keep":
|
||||||
|
await send_main_menu(callback,
|
||||||
|
settings,
|
||||||
|
i18n_data,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
is_edit=False)
|
||||||
else:
|
else:
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(
|
_ = lambda key, **kwargs: i18n.gettext(
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ router.include_router(payments.router)
|
|||||||
router.include_router(payment_methods.router)
|
router.include_router(payment_methods.router)
|
||||||
|
|
||||||
# Re-export commonly used entrypoints for backward compatibility
|
# Re-export commonly used entrypoints for backward compatibility
|
||||||
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,57 @@ async def my_subscription_command_handler(
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
max_devices_value = active.get("max_devices")
|
||||||
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
current_devices_display = "?"
|
||||||
|
user_uuid = active.get("user_id")
|
||||||
|
devices_response = None
|
||||||
|
if user_uuid:
|
||||||
|
try:
|
||||||
|
devices_response = await panel_service.get_user_devices(user_uuid)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to load devices for user %s", user_uuid)
|
||||||
|
if devices_response:
|
||||||
|
devices_count: Optional[int] = None
|
||||||
|
if isinstance(devices_response, dict):
|
||||||
|
devices_list = devices_response.get("devices")
|
||||||
|
if isinstance(devices_list, list):
|
||||||
|
devices_count = len(devices_list)
|
||||||
|
elif isinstance(devices_list, int):
|
||||||
|
devices_count = devices_list
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
devices_count = len(devices_list) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
devices_count = None
|
||||||
|
if devices_count is None:
|
||||||
|
total_value = devices_response.get("total")
|
||||||
|
if isinstance(total_value, int):
|
||||||
|
devices_count = total_value
|
||||||
|
elif isinstance(devices_response, list):
|
||||||
|
devices_count = len(devices_response)
|
||||||
|
if devices_count is not None:
|
||||||
|
current_devices_display = str(devices_count)
|
||||||
|
devices_button_text = get_text(
|
||||||
|
"devices_button",
|
||||||
|
current_devices=current_devices_display,
|
||||||
|
max_devices=max_devices_display,
|
||||||
|
)
|
||||||
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=devices_button_text,
|
||||||
|
callback_data="main_action:my_devices",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
# 2) Auto-renew toggle (if supported and not tribute)
|
# 2) Auto-renew toggle (if supported and not tribute)
|
||||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
toggle_text = (
|
toggle_text = (
|
||||||
@@ -216,6 +267,161 @@ async def my_subscription_command_handler(
|
|||||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "main_action:my_devices")
|
||||||
|
async def my_devices_command_handler(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||||
|
|
||||||
|
if not i18n or not target:
|
||||||
|
if isinstance(event, types.Message):
|
||||||
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("my_devices_feature_disabled"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: context?
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
|
if not active or not active.get("user_id"):
|
||||||
|
message = get_text("subscription_not_active")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(message, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
|
||||||
|
if not devices:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("no_devices_found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
max_devices_value = active.get("max_devices")
|
||||||
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
|
||||||
|
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||||
|
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||||
|
else:
|
||||||
|
devices_list = []
|
||||||
|
current_devices = len(devices.get('devices') or [])
|
||||||
|
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||||
|
device_model = device.get('deviceModel') or None
|
||||||
|
platform = device.get('platform') or None
|
||||||
|
user_agent = device.get('userAgent') or None
|
||||||
|
os_version = device.get('osVersion') or None
|
||||||
|
created_at = device.get('createdAt')
|
||||||
|
hwid = device.get('hwid')
|
||||||
|
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
|
||||||
|
|
||||||
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
|
||||||
|
kb = base_markup.inline_keyboard
|
||||||
|
|
||||||
|
devices_kb = []
|
||||||
|
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||||
|
hwid = device.get('hwid')
|
||||||
|
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||||
|
|
||||||
|
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||||
|
kb = devices_kb + kb
|
||||||
|
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text, reply_markup=markup)
|
||||||
|
except Exception:
|
||||||
|
await event.message.answer(text, reply_markup=markup)
|
||||||
|
else:
|
||||||
|
await target.answer(text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("disconnect_device:"))
|
||||||
|
async def disconnect_device_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
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 settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, hwid = callback.data.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||||
|
if not active:
|
||||||
|
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||||
|
if not success:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
await session.commit()
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("device_disconnected"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||||
async def toggle_autorenew_handler(
|
async def toggle_autorenew_handler(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
@@ -359,5 +565,3 @@ async def connect_command_handler(
|
|||||||
):
|
):
|
||||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -118,14 +118,17 @@ def get_payment_method_keyboard(months: int, price: float,
|
|||||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
i18n_instance, settings: Settings) -> 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.STARS_ENABLED and stars_price is not None:
|
if settings.FREEKASSA_ENABLED:
|
||||||
builder.button(text=_("pay_with_stars_button"),
|
builder.button(text=_("pay_with_sbp_button"),
|
||||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
callback_data=f"pay_fk:{months}:{price}")
|
||||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
|
||||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
|
||||||
if settings.YOOKASSA_ENABLED:
|
if settings.YOOKASSA_ENABLED:
|
||||||
builder.button(text=_("pay_with_yookassa_button"),
|
builder.button(text=_("pay_with_yookassa_button"),
|
||||||
callback_data=f"pay_yk:{months}:{price}")
|
callback_data=f"pay_yk:{months}:{price}")
|
||||||
|
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||||
|
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||||
|
if settings.STARS_ENABLED and stars_price is not None:
|
||||||
|
builder.button(text=_("pay_with_stars_button"),
|
||||||
|
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||||
if settings.CRYPTOPAY_ENABLED:
|
if settings.CRYPTOPAY_ENABLED:
|
||||||
builder.button(text=_("pay_with_cryptopay_button"),
|
builder.button(text=_("pay_with_cryptopay_button"),
|
||||||
callback_data=f"pay_crypto:{months}:{price}")
|
callback_data=f"pay_crypto:{months}:{price}")
|
||||||
@@ -135,17 +138,115 @@ def get_payment_method_keyboard(months: int, price: float,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_payment_url_keyboard(payment_url: str, lang: str,
|
def get_payment_url_keyboard(payment_url: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
back_callback: Optional[str] = None,
|
||||||
|
back_text_key: str = "back_to_main_menu_button"
|
||||||
|
) -> 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="pay_button"), url=payment_url)
|
builder.button(text=_(key="pay_button"), url=payment_url)
|
||||||
builder.button(text=_(key="back_to_main_menu_button"),
|
if back_callback:
|
||||||
callback_data="main_action:back_to_main")
|
builder.button(text=_(key=back_text_key), callback_data=back_callback)
|
||||||
|
else:
|
||||||
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data="main_action:back_to_main")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_yk_autopay_choice_keyboard(
|
||||||
|
months: int,
|
||||||
|
price: float,
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
has_saved_cards: bool = True,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""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)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
price_str = str(price)
|
||||||
|
if has_saved_cards:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
|
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="back_to_payment_methods_button"),
|
||||||
|
callback_data=f"subscribe_period:{months}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_yk_saved_cards_keyboard(
|
||||||
|
cards: List[Tuple[str, str]],
|
||||||
|
months: int,
|
||||||
|
price: float,
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
page: int = 0,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
per_page = 5
|
||||||
|
total = len(cards)
|
||||||
|
start = page * per_page
|
||||||
|
end = min(total, start + per_page)
|
||||||
|
price_str = str(price)
|
||||||
|
|
||||||
|
for method_id, title in cards[start:end]:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=title,
|
||||||
|
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
nav_buttons: List[InlineKeyboardButton] = []
|
||||||
|
if start > 0:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️",
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if end < total:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➡️",
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if nav_buttons:
|
||||||
|
builder.row(*nav_buttons)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
|
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="back_to_autopay_method_choice_button"),
|
||||||
|
callback_data=f"pay_yk:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_referral_link_keyboard(lang: str,
|
def get_referral_link_keyboard(lang: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
i18n_instance) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
@@ -159,11 +260,16 @@ def get_referral_link_keyboard(lang: str,
|
|||||||
|
|
||||||
|
|
||||||
def get_back_to_main_menu_markup(lang: str,
|
def get_back_to_main_menu_markup(lang: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
i18n_instance,
|
||||||
|
callback_data: Optional[str] = None) -> 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="back_to_main_menu_button"),
|
if callback_data:
|
||||||
callback_data="main_action:back_to_main")
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data=callback_data)
|
||||||
|
else:
|
||||||
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data="main_action:back_to_main")
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -185,11 +291,49 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_subscription_keyboard(
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
channel_link: Optional[str],
|
||||||
|
include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]:
|
||||||
|
"""
|
||||||
|
Return keyboard with buttons to open the required channel and trigger a subscription re-check.
|
||||||
|
"""
|
||||||
|
if i18n_instance is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
has_buttons = False
|
||||||
|
|
||||||
|
if channel_link:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="channel_subscription_join_button"),
|
||||||
|
url=channel_link,
|
||||||
|
)
|
||||||
|
has_buttons = True
|
||||||
|
|
||||||
|
if include_check_button:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="channel_subscription_verify_button"),
|
||||||
|
callback_data="channel_subscription:verify",
|
||||||
|
)
|
||||||
|
has_buttons = True
|
||||||
|
|
||||||
|
if not has_buttons:
|
||||||
|
return None
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_connect_and_main_keyboard(
|
def get_connect_and_main_keyboard(
|
||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
config_link: Optional[str]) -> InlineKeyboardMarkup:
|
config_link: Optional[str],
|
||||||
|
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()
|
||||||
@@ -213,10 +357,11 @@ def get_connect_and_main_keyboard(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_callback = "main_action:back_to_main_keep" if preserve_message else "main_action:back_to_main"
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("back_to_main_menu_button"),
|
text=_("back_to_main_menu_button"),
|
||||||
callback_data="main_action:back_to_main",
|
callback_data=back_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
for service_key in (
|
for service_key in (
|
||||||
"panel_service",
|
"panel_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
|
"freekassa_service",
|
||||||
"tribute_service",
|
"tribute_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
Message,
|
||||||
|
Update,
|
||||||
|
)
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import user_dal
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Blocks access to handlers for users who have not yet passed the required channel subscription check.
|
||||||
|
The /start command is allowed through so that the handler can re-run the verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
|
||||||
|
super().__init__()
|
||||||
|
self.settings = settings
|
||||||
|
self.i18n_main_instance = i18n_instance
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Update,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||||
|
if not required_channel_id:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
event_user = data.get("event_from_user")
|
||||||
|
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Allow /start to reach the handler so the check can be re-run.
|
||||||
|
message_object: Optional[Message] = event.message
|
||||||
|
if (
|
||||||
|
message_object
|
||||||
|
and message_object.text
|
||||||
|
and message_object.text.startswith("/start")
|
||||||
|
):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
session: AsyncSession = data["session"]
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, event_user.id)
|
||||||
|
except Exception as db_error:
|
||||||
|
logging.error(
|
||||||
|
"ChannelSubscriptionMiddleware: failed to fetch user %s: %s",
|
||||||
|
event_user.id,
|
||||||
|
db_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
db_user.channel_subscription_verified
|
||||||
|
and db_user.channel_subscription_verified_for == required_channel_id
|
||||||
|
):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
i18n_payload: Dict[str, Any] = data.get("i18n_data", {})
|
||||||
|
current_lang: str = i18n_payload.get(
|
||||||
|
"current_language", self.settings.DEFAULT_LANGUAGE
|
||||||
|
)
|
||||||
|
i18n_instance: Optional[JsonI18n] = i18n_payload.get(
|
||||||
|
"i18n_instance", self.i18n_main_instance
|
||||||
|
)
|
||||||
|
|
||||||
|
def translate(key: str) -> str:
|
||||||
|
if i18n_instance:
|
||||||
|
return i18n_instance.gettext(current_lang, key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
keyboard = (
|
||||||
|
get_channel_subscription_keyboard(
|
||||||
|
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
||||||
|
)
|
||||||
|
if i18n_instance
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
|
if event.callback_query:
|
||||||
|
await self._handle_callback(event.callback_query, prompt_text, keyboard, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message_object:
|
||||||
|
await message_object.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
else:
|
||||||
|
bot_instance = data["bot"]
|
||||||
|
await bot_instance.send_message(
|
||||||
|
chat_id=event_user.id,
|
||||||
|
text=prompt_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _handle_callback(
|
||||||
|
self,
|
||||||
|
callback: CallbackQuery,
|
||||||
|
prompt_text: str,
|
||||||
|
keyboard,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await callback.answer(prompt_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
|
try:
|
||||||
|
await callback.message.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
except Exception as send_error:
|
||||||
|
logging.error(
|
||||||
|
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s",
|
||||||
|
callback.message.chat.id,
|
||||||
|
send_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bot_instance = data["bot"]
|
||||||
|
await bot_instance.send_message(
|
||||||
|
chat_id=callback.from_user.id,
|
||||||
|
text=prompt_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
@@ -209,7 +209,9 @@ class CryptoPayService:
|
|||||||
end_date=final_end.strftime('%Y-%m-%d'),
|
end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link)
|
config_link=config_link)
|
||||||
|
|
||||||
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang, i18n, settings, config_link, preserve_message=True
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class FreeKassaService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
):
|
||||||
|
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.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||||
|
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||||
|
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||||
|
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||||
|
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||||
|
|
||||||
|
self.api_base_url: str = "https://api.fk.life/v1"
|
||||||
|
self._timeout = ClientTimeout(total=15)
|
||||||
|
self._session: Optional[ClientSession] = None
|
||||||
|
self._nonce_lock = asyncio.Lock()
|
||||||
|
self._last_nonce = int(time.time() * 1000)
|
||||||
|
|
||||||
|
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
|
||||||
|
if not self.configured:
|
||||||
|
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
||||||
|
if settings.FREEKASSA_ENABLED and not self.server_ip:
|
||||||
|
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_amount(amount: float) -> str:
|
||||||
|
"""Format amount for payloads and signature with two decimal places."""
|
||||||
|
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"{quantized:.2f}"
|
||||||
|
|
||||||
|
async def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payment_db_id: int,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
|
method_code: int,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
extra_params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
if not self.configured:
|
||||||
|
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||||
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
|
ip_address = ip_address or self.server_ip
|
||||||
|
if not ip_address:
|
||||||
|
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||||
|
return False, {"message": "missing_ip"}
|
||||||
|
|
||||||
|
email = email or f"{user_id}@telegram.org"
|
||||||
|
amount_str = self._format_amount(amount)
|
||||||
|
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"shopId": int(self.shop_id),
|
||||||
|
"nonce": await self._generate_nonce(),
|
||||||
|
"paymentId": str(payment_db_id),
|
||||||
|
"i": int(method_code),
|
||||||
|
"amount": amount_str,
|
||||||
|
"currency": currency_code,
|
||||||
|
"email": email,
|
||||||
|
"ip": ip_address,
|
||||||
|
"us_user_id": str(user_id),
|
||||||
|
"us_months": str(months),
|
||||||
|
"us_payment_db_id": str(payment_db_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra_params:
|
||||||
|
for key, value in extra_params.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
payload[key] = value
|
||||||
|
|
||||||
|
payload["signature"] = self._sign_payload(payload)
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.api_base_url}/orders/create"
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=payload) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
|
||||||
|
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||||
|
|
||||||
|
if response.status != 200 or response_data.get("type") != "success":
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa create_order: 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("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||||
|
return False, {"message": str(exc)}
|
||||||
|
|
||||||
|
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 _generate_nonce(self) -> int:
|
||||||
|
async with self._nonce_lock:
|
||||||
|
candidate = int(time.time() * 1000)
|
||||||
|
if candidate <= self._last_nonce:
|
||||||
|
candidate = self._last_nonce + 1
|
||||||
|
self._last_nonce = candidate
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("FreeKassa API key is not configured.")
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "signature" and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
def _validate_signature(
|
||||||
|
self,
|
||||||
|
merchant_order_id: str,
|
||||||
|
amount: str,
|
||||||
|
provided_signature: str,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not provided_signature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.shop_id and self.second_secret:
|
||||||
|
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||||
|
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||||
|
if expected_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.api_key and payload:
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"signature", "SIGN"} and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if alt_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
|
if not self.configured:
|
||||||
|
return web.Response(status=503, text="freekassa_disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.post()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||||
|
return web.Response(status=400, text="bad_request")
|
||||||
|
|
||||||
|
payload_dict: Dict[str, Any]
|
||||||
|
if data:
|
||||||
|
payload_dict = {str(k): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json_payload = await request.json()
|
||||||
|
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||||
|
data = json_payload
|
||||||
|
except Exception:
|
||||||
|
payload_dict = {}
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get(key) or data.get(key.lower()) or default
|
||||||
|
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||||
|
|
||||||
|
merchant_id = _get("MERCHANT_ID")
|
||||||
|
if merchant_id != self.shop_id:
|
||||||
|
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||||
|
return web.Response(status=403, text="merchant_mismatch")
|
||||||
|
|
||||||
|
signature = _get("SIGN") or _get("signature")
|
||||||
|
if not signature:
|
||||||
|
logging.error("FreeKassa webhook: missing signature")
|
||||||
|
return web.Response(status=400, text="missing_signature")
|
||||||
|
|
||||||
|
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||||
|
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
|
||||||
|
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||||
|
|
||||||
|
if not order_id_str or not amount_str:
|
||||||
|
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||||
|
return web.Response(status=400, text="missing_data")
|
||||||
|
|
||||||
|
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||||
|
logging.error("FreeKassa webhook: invalid signature")
|
||||||
|
return web.Response(status=403, text="invalid_signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_db_id = int(order_id_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
|
||||||
|
return web.Response(status=400, text="invalid_order_id")
|
||||||
|
|
||||||
|
async with self.async_session_factory() as session:
|
||||||
|
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
|
if not payment:
|
||||||
|
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||||
|
return web.Response(status=404, text="payment_not_found")
|
||||||
|
|
||||||
|
if payment.status == "succeeded":
|
||||||
|
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||||
|
return web.Response(text="YES")
|
||||||
|
|
||||||
|
# Optional amount verification
|
||||||
|
try:
|
||||||
|
amount_decimal = Decimal(amount_str)
|
||||||
|
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
|
||||||
|
logging.warning(
|
||||||
|
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||||
|
f"(expected {expected_amount}, got {amount_decimal})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||||
|
|
||||||
|
activation = None
|
||||||
|
referral_bonus = None
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session=session,
|
||||||
|
payment_db_id=payment.payment_id,
|
||||||
|
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||||
|
new_status="succeeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
months = payment.subscription_duration_months or 1
|
||||||
|
|
||||||
|
activation = await self.subscription_service.activate_subscription(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
months,
|
||||||
|
float(payment.amount),
|
||||||
|
payment.payment_id,
|
||||||
|
provider="freekassa",
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
months,
|
||||||
|
current_payment_db_id=payment.payment_id,
|
||||||
|
skip_if_active_before_payment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
|
||||||
|
return web.Response(status=500, text="processing_error")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
config_link = None
|
||||||
|
final_end = None
|
||||||
|
months = payment.subscription_duration_months or 1
|
||||||
|
if activation:
|
||||||
|
config_link = activation.get("subscription_url")
|
||||||
|
final_end = activation.get("end_date")
|
||||||
|
|
||||||
|
applied_days = 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)
|
||||||
|
|
||||||
|
if not final_end and activation and activation.get("end_date"):
|
||||||
|
final_end = activation["end_date"]
|
||||||
|
|
||||||
|
if not config_link:
|
||||||
|
config_link = _("config_link_not_available")
|
||||||
|
if final_end:
|
||||||
|
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end_date_str = _("config_link_not_available")
|
||||||
|
|
||||||
|
if 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=months,
|
||||||
|
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
|
||||||
|
bonus_days=applied_days,
|
||||||
|
final_end_date=end_date_str,
|
||||||
|
inviter_name=inviter_name_display,
|
||||||
|
config_link=config_link,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_full",
|
||||||
|
months=months,
|
||||||
|
end_date=end_date_str,
|
||||||
|
config_link=config_link,
|
||||||
|
)
|
||||||
|
if provider_payment_id:
|
||||||
|
order_info_text = _(
|
||||||
|
"free_kassa_order_full",
|
||||||
|
order_id=provider_payment_id,
|
||||||
|
date=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
text = f"{order_info_text}\n{text}"
|
||||||
|
|
||||||
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang,
|
||||||
|
self.i18n,
|
||||||
|
self.settings,
|
||||||
|
config_link,
|
||||||
|
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 e:
|
||||||
|
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
|
||||||
|
|
||||||
|
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=self.default_currency,
|
||||||
|
months=months,
|
||||||
|
payment_provider="freekassa",
|
||||||
|
username=db_user.username if db_user else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
|
||||||
|
|
||||||
|
return web.Response(text="YES")
|
||||||
|
|
||||||
|
|
||||||
|
async def freekassa_webhook_route(request: web.Request) -> web.Response:
|
||||||
|
service: FreeKassaService = request.app["freekassa_service"]
|
||||||
|
return await service.webhook_route(request)
|
||||||
@@ -158,6 +158,7 @@ class NotificationService:
|
|||||||
|
|
||||||
provider_emoji = {
|
provider_emoji = {
|
||||||
"yookassa": "💳",
|
"yookassa": "💳",
|
||||||
|
"freekassa": "💳",
|
||||||
"cryptopay": "₿",
|
"cryptopay": "₿",
|
||||||
"stars": "⭐",
|
"stars": "⭐",
|
||||||
"tribute": "💎"
|
"tribute": "💎"
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ class PanelApiService:
|
|||||||
self.api_key = settings.PANEL_API_KEY
|
self.api_key = settings.PANEL_API_KEY
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
self.default_client_ip = "127.0.0.1"
|
self.default_client_ip = "127.0.0.1"
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Context manager entry"""
|
"""Context manager entry"""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""Context manager exit - automatically close session"""
|
"""Context manager exit - automatically close session"""
|
||||||
await self.close_session()
|
await self.close_session()
|
||||||
@@ -337,6 +337,7 @@ class PanelApiService:
|
|||||||
default_expire_days: int = 1,
|
default_expire_days: int = 1,
|
||||||
default_traffic_limit_bytes: int = 0,
|
default_traffic_limit_bytes: int = 0,
|
||||||
default_traffic_limit_strategy: str = "NO_RESET",
|
default_traffic_limit_strategy: str = "NO_RESET",
|
||||||
|
hwid_device_limit: Optional[int] = None,
|
||||||
specific_squad_uuids: Optional[List[str]] = None,
|
specific_squad_uuids: Optional[List[str]] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
@@ -368,6 +369,18 @@ class PanelApiService:
|
|||||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||||
}
|
}
|
||||||
|
hwid_limit_value = hwid_device_limit
|
||||||
|
if hwid_limit_value is None:
|
||||||
|
hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
if hwid_limit_value is not None:
|
||||||
|
try:
|
||||||
|
hwid_limit_int = int(hwid_limit_value)
|
||||||
|
if hwid_limit_int >= 0:
|
||||||
|
payload["hwidDeviceLimit"] = hwid_limit_int
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.warning(
|
||||||
|
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
|
||||||
|
)
|
||||||
if specific_squad_uuids:
|
if specific_squad_uuids:
|
||||||
payload["activeInternalSquads"] = specific_squad_uuids
|
payload["activeInternalSquads"] = specific_squad_uuids
|
||||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||||
@@ -455,6 +468,30 @@ class PanelApiService:
|
|||||||
return f"{base_sub_url}/{client_type.lower()}"
|
return f"{base_sub_url}/{client_type.lower()}"
|
||||||
return base_sub_url
|
return base_sub_url
|
||||||
|
|
||||||
|
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
endpoint = f"/hwid/devices/{user_uuid}"
|
||||||
|
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return response_data.get("response")
|
||||||
|
logging.error(
|
||||||
|
f"Failed to get user devices for user {user_uuid}. Response: {response_data}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def disconnect_device(self, user_uuid: str, hwid: str) -> bool:
|
||||||
|
endpoint = f"/hwid/devices/delete"
|
||||||
|
payload = {
|
||||||
|
"userUuid": user_uuid,
|
||||||
|
"hwid": hwid
|
||||||
|
}
|
||||||
|
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return True
|
||||||
|
logging.error(
|
||||||
|
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def update_bot_db_sync_status(self,
|
async def update_bot_db_sync_status(self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
status: str,
|
status: str,
|
||||||
@@ -468,25 +505,24 @@ class PanelApiService:
|
|||||||
async def get_bot_db_last_sync_status(
|
async def get_bot_db_last_sync_status(
|
||||||
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||||
return await panel_sync_dal.get_panel_sync_status(session)
|
return await panel_sync_dal.get_panel_sync_status(session)
|
||||||
|
|
||||||
|
|
||||||
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get system statistics (CPU, memory, users counts)"""
|
"""Get system statistics (CPU, memory, users counts)"""
|
||||||
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get bandwidth statistics"""
|
"""Get bandwidth statistics"""
|
||||||
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get nodes statistics"""
|
"""Get nodes statistics"""
|
||||||
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class StarsService:
|
|||||||
config_link=config_link,
|
config_link=config_link,
|
||||||
)
|
)
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, self.settings, config_link
|
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class SubscriptionService:
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "TRIAL",
|
"status_from_panel": "TRIAL",
|
||||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||||
@@ -592,10 +593,10 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
start_date = datetime.now(timezone.utc)
|
start_date = datetime.now(timezone.utc)
|
||||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||||
|
|
||||||
# For promo code activations, use the configured user traffic limit
|
# For promo code activations, use the configured user traffic limit
|
||||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
||||||
|
|
||||||
bonus_sub_payload = {
|
bonus_sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"panel_user_uuid": panel_uuid,
|
"panel_user_uuid": panel_uuid,
|
||||||
@@ -634,7 +635,7 @@ class SubscriptionService:
|
|||||||
),
|
),
|
||||||
include_uuid=False,
|
include_uuid=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_update_success = (
|
panel_update_success = (
|
||||||
await self.panel_service.update_user_details_on_panel(
|
await self.panel_service.update_user_details_on_panel(
|
||||||
panel_uuid,
|
panel_uuid,
|
||||||
@@ -738,8 +739,12 @@ class SubscriptionService:
|
|||||||
if panel_user_data.get("expireAt")
|
if panel_user_data.get("expireAt")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
|
if hwid_limit is None:
|
||||||
|
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"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": panel_user_data.get("subscriptionUrl"),
|
||||||
@@ -747,6 +752,7 @@ class SubscriptionService:
|
|||||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
"traffic_used_bytes": panel_user_data.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,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_subscriptions_ending_soon(
|
async def get_subscriptions_ending_soon(
|
||||||
|
|||||||
@@ -208,7 +208,11 @@ class TributeService:
|
|||||||
config_link=config_link,
|
config_link=config_link,
|
||||||
)
|
)
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
lang, i18n, settings, config_link
|
lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link,
|
||||||
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class MessageQueue:
|
|||||||
self.last_send_times: deque[datetime] = deque()
|
self.last_send_times: deque[datetime] = deque()
|
||||||
self.is_processing = False
|
self.is_processing = False
|
||||||
self.delay_between_messages = 1.0 / messages_per_second
|
self.delay_between_messages = 1.0 / messages_per_second
|
||||||
|
self.total_sent = 0
|
||||||
|
self.total_failed = 0
|
||||||
|
|
||||||
async def add_message(self, message: QueuedMessage) -> None:
|
async def add_message(self, message: QueuedMessage) -> None:
|
||||||
"""Add message to queue"""
|
"""Add message to queue"""
|
||||||
@@ -50,6 +52,7 @@ class MessageQueue:
|
|||||||
try:
|
try:
|
||||||
await self._send_message(message)
|
await self._send_message(message)
|
||||||
self.last_send_times.append(datetime.now())
|
self.last_send_times.append(datetime.now())
|
||||||
|
self.total_sent += 1
|
||||||
|
|
||||||
# Keep only recent send times (last minute)
|
# Keep only recent send times (last minute)
|
||||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||||
@@ -57,6 +60,7 @@ class MessageQueue:
|
|||||||
self.last_send_times.popleft()
|
self.last_send_times.popleft()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.total_failed += 1
|
||||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -233,7 +237,11 @@ class MessageQueueManager:
|
|||||||
"group_queue_processing": self.group_queue.is_processing,
|
"group_queue_processing": self.group_queue.is_processing,
|
||||||
"user_queue_processing": self.user_queue.is_processing,
|
"user_queue_processing": self.user_queue.is_processing,
|
||||||
"group_recent_sends": len(self.group_queue.last_send_times),
|
"group_recent_sends": len(self.group_queue.last_send_times),
|
||||||
"user_recent_sends": len(self.user_queue.last_send_times)
|
"user_recent_sends": len(self.user_queue.last_send_times),
|
||||||
|
"group_failed_messages": self.group_queue.total_failed,
|
||||||
|
"user_failed_messages": self.user_queue.total_failed,
|
||||||
|
"group_sent_messages": self.group_queue.total_sent,
|
||||||
|
"user_sent_messages": self.user_queue.total_sent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -250,4 +258,4 @@ def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
|||||||
|
|
||||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||||
"""Get global queue manager instance"""
|
"""Get global queue manager instance"""
|
||||||
return _queue_manager
|
return _queue_manager
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class Settings(BaseSettings):
|
|||||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||||
|
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Telegram channel ID the user must join to access the bot")
|
||||||
|
REQUIRED_CHANNEL_LINK: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Public username or invite link to the required channel for join button")
|
||||||
|
|
||||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||||
@@ -44,6 +50,14 @@ class Settings(BaseSettings):
|
|||||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||||
|
|
||||||
|
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||||
|
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||||
|
FREEKASSA_FIRST_SECRET: Optional[str] = None
|
||||||
|
FREEKASSA_SECOND_SECRET: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||||
|
FREEKASSA_API_KEY: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||||
|
|
||||||
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)
|
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||||
@@ -123,6 +137,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||||
|
|
||||||
|
MY_DEVICES_SECTION_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Enable the My Devices section in the subscription menu"
|
||||||
|
)
|
||||||
|
USER_HWID_DEVICE_LIMIT: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Default hardware device limit for panel users (0 = unlimited)"
|
||||||
|
)
|
||||||
|
|
||||||
# Inline mode thumbnail URLs
|
# Inline mode thumbnail URLs
|
||||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||||
@@ -236,6 +259,19 @@ class Settings(BaseSettings):
|
|||||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def freekassa_webhook_path(self) -> str:
|
||||||
|
return "/webhook/freekassa"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def freekassa_full_webhook_url(self) -> Optional[str]:
|
||||||
|
base = self.WEBHOOK_BASE_URL
|
||||||
|
if base:
|
||||||
|
return f"{base.rstrip('/')}{self.freekassa_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
|
||||||
@@ -331,6 +367,22 @@ class Settings(BaseSettings):
|
|||||||
if isinstance(v, str) and v.strip() == '':
|
if isinstance(v, str) and v.strip() == '':
|
||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def sanitize_optional_link(cls, v):
|
||||||
|
if isinstance(v, str) and not v.strip():
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_optional_int(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
# Notification types
|
# Notification types
|
||||||
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
||||||
@@ -366,6 +418,22 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||||
)
|
)
|
||||||
|
if _settings_instance.FREEKASSA_ENABLED:
|
||||||
|
if (
|
||||||
|
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||||
|
or not _settings_instance.FREEKASSA_API_KEY
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work."
|
||||||
|
)
|
||||||
|
if not _settings_instance.FREEKASSA_SECOND_SECRET:
|
||||||
|
logging.warning(
|
||||||
|
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified."
|
||||||
|
)
|
||||||
|
if not _settings_instance.subscription_options:
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||||
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logging.critical(
|
logging.critical(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from .models import Base
|
from .models import Base
|
||||||
from .migrator import run_simple_migrations
|
from .migrator import run_database_migrations
|
||||||
|
|
||||||
async_engine = None
|
async_engine = None
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
|||||||
|
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Run lightweight, idempotent migrations to add any missing columns
|
await conn.run_sync(run_database_migrations)
|
||||||
await conn.run_sync(run_simple_migrations)
|
|
||||||
logging.info(
|
logging.info(
|
||||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||||
)
|
)
|
||||||
|
|||||||
+85
-53
@@ -1,66 +1,98 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Set
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, List, Set
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from .models import Base
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Migration:
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
upgrade: Callable[[Connection], None]
|
||||||
|
|
||||||
|
|
||||||
def _add_missing_columns(connection: Connection) -> None:
|
def _ensure_migrations_table(connection: Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
|
||||||
inspector = inspect(connection)
|
inspector = inspect(connection)
|
||||||
metadata = Base.metadata
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
statements: List[str] = []
|
||||||
|
|
||||||
existing_tables: Set[str] = set(inspector.get_table_names())
|
if "channel_subscription_verified" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||||
|
)
|
||||||
|
if "channel_subscription_checked_at" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||||
|
)
|
||||||
|
if "channel_subscription_verified_for" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||||
|
)
|
||||||
|
|
||||||
for table in metadata.tables.values():
|
for stmt in statements:
|
||||||
table_name = table.name
|
connection.execute(text(stmt))
|
||||||
if table_name not in existing_tables:
|
|
||||||
# Tables are created elsewhere via create_all; skip here.
|
|
||||||
|
MIGRATIONS: List[Migration] = [
|
||||||
|
Migration(
|
||||||
|
id="0001_add_channel_subscription_fields",
|
||||||
|
description="Add columns to track required channel subscription verification",
|
||||||
|
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_database_migrations(connection: Connection) -> None:
|
||||||
|
"""
|
||||||
|
Apply pending migrations sequentially. Already applied revisions are skipped.
|
||||||
|
"""
|
||||||
|
_ensure_migrations_table(connection)
|
||||||
|
|
||||||
|
applied_revisions: Set[str] = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
text("SELECT id FROM schema_migrations")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for migration in MIGRATIONS:
|
||||||
|
if migration.id in applied_revisions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
|
logging.info(
|
||||||
|
"Migrator: applying %s – %s", migration.id, migration.description
|
||||||
for desired_column in table.columns:
|
)
|
||||||
if desired_column.name in existing_columns:
|
try:
|
||||||
continue
|
with connection.begin_nested():
|
||||||
|
migration.upgrade(connection)
|
||||||
# Build ADD COLUMN DDL
|
connection.execute(
|
||||||
preparer = connection.dialect.identifier_preparer
|
text(
|
||||||
table_quoted = preparer.format_table(table)
|
"INSERT INTO schema_migrations (id) VALUES (:revision)"
|
||||||
column_name_quoted = preparer.quote(desired_column.name)
|
),
|
||||||
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
|
{"revision": migration.id},
|
||||||
|
)
|
||||||
default_clause = ""
|
except Exception as exc:
|
||||||
server_default = getattr(desired_column, "server_default", None)
|
logging.error(
|
||||||
if server_default is not None and getattr(server_default, "arg", None) is not None:
|
"Migrator: failed to apply %s (%s)",
|
||||||
try:
|
migration.id,
|
||||||
compiled_default = str(
|
migration.description,
|
||||||
server_default.arg.compile(dialect=connection.dialect)
|
exc_info=True,
|
||||||
)
|
|
||||||
default_clause = f" DEFAULT {compiled_default}"
|
|
||||||
except Exception: # best-effort
|
|
||||||
pass
|
|
||||||
|
|
||||||
# For safety, add new columns as NULLable to avoid failures on existing rows
|
|
||||||
# If strict NOT NULL is needed, it can be enforced manually later.
|
|
||||||
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
|
|
||||||
)
|
)
|
||||||
connection.execute(text(ddl))
|
raise exc
|
||||||
|
else:
|
||||||
|
logging.info("Migrator: migration %s applied successfully", migration.id)
|
||||||
def run_simple_migrations(connection: Connection) -> None:
|
|
||||||
"""
|
|
||||||
Run lightweight, idempotent migrations:
|
|
||||||
- Ensure missing columns are added to existing tables to match models in db/models.py
|
|
||||||
Note: Table creation is handled separately via Base.metadata.create_all.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
_add_missing_columns(connection)
|
|
||||||
logging.info("Migrator: schema synchronized (columns added as needed).")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class User(Base):
|
|||||||
referred_by_id = Column(BigInteger,
|
referred_by_id = Column(BigInteger,
|
||||||
ForeignKey("users.user_id"),
|
ForeignKey("users.user_id"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
|
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||||
|
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||||
|
nullable=True)
|
||||||
|
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
||||||
subscriptions = relationship("Subscription",
|
subscriptions = relationship("Subscription",
|
||||||
|
|||||||
+30
-11
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Welcome, {user_name}!",
|
"welcome": "Welcome, {user_name}!",
|
||||||
|
"channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".",
|
||||||
|
"channel_subscription_join_button": "Open channel",
|
||||||
|
"channel_subscription_verify_button": "Check subscription",
|
||||||
|
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
||||||
|
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
|
||||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||||
|
|
||||||
"menu_activate_trial_button": "🆓 Free Trial",
|
"menu_activate_trial_button": "🆓 Free Trial",
|
||||||
"menu_subscribe_inline": "🚀 Purchase",
|
"menu_subscribe_inline": "🚀 Purchase",
|
||||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||||
"no_subscription options available": "Subscription issuance is not configured by the bot administrator",
|
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
|
||||||
"menu_referral_inline": "🎁 Referrals",
|
"menu_referral_inline": "🎁 Referrals",
|
||||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||||
"menu_apply_promo_button": "🎟 Promo Code",
|
"menu_apply_promo_button": "🎟 Promo Code",
|
||||||
@@ -13,37 +17,53 @@
|
|||||||
"menu_server_status_button": "📊 Status",
|
"menu_server_status_button": "📊 Status",
|
||||||
"menu_support_button": "💬 Support",
|
"menu_support_button": "💬 Support",
|
||||||
"menu_terms_button": "📄 Terms of Service",
|
"menu_terms_button": "📄 Terms of Service",
|
||||||
|
|
||||||
"back_to_main_menu_button": "⬅️ Back",
|
"back_to_main_menu_button": "⬅️ Back",
|
||||||
|
|
||||||
"choose_language": "Choose language:",
|
"choose_language": "Choose language:",
|
||||||
"language_set_alert": "Language changed!",
|
"language_set_alert": "Language changed!",
|
||||||
|
|
||||||
"error_occurred_try_again": "An error occurred, please try again.",
|
"error_occurred_try_again": "An error occurred, please try again.",
|
||||||
"error_try_again": "Please try again.",
|
"error_try_again": "Please try again.",
|
||||||
"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:",
|
||||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||||
|
|
||||||
"choose_payment_method": "Choose payment method:",
|
"choose_payment_method": "Choose payment method:",
|
||||||
"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_pay_saved_card_button": "💳 Pay with saved card",
|
||||||
|
"yookassa_autopay_pay_new_card_button": "➕ Pay with new card",
|
||||||
|
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
|
||||||
|
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
|
||||||
|
"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.",
|
||||||
|
"pay_with_sbp_button": "📱 SBP",
|
||||||
|
"back_to_payment_methods_button": "⬅️ Back",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"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",
|
||||||
|
|
||||||
|
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||||
|
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||||
|
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||||
|
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nDate connected: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||||
|
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
|
||||||
|
"device_disconnected": "Device disconnected successfully.",
|
||||||
|
"no_devices_found": "Error loading device list.",
|
||||||
|
"devices_unlimited_label": "Unlimited",
|
||||||
|
"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_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:",
|
||||||
|
"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_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_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",
|
||||||
"traffic_unlimited": "Unlimited",
|
"traffic_unlimited": "Unlimited",
|
||||||
|
|
||||||
"promo_code_prompt": "Please enter your promo code:",
|
"promo_code_prompt": "Please enter your promo code:",
|
||||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||||
@@ -65,7 +85,6 @@
|
|||||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||||
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
||||||
|
|
||||||
"admin_panel_title": "Admin Panel",
|
"admin_panel_title": "Admin Panel",
|
||||||
"admin_stats_button": "📊 Statistics",
|
"admin_stats_button": "📊 Statistics",
|
||||||
"admin_broadcast_button": "📢 Broadcast",
|
"admin_broadcast_button": "📢 Broadcast",
|
||||||
@@ -322,7 +341,6 @@
|
|||||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||||
"admin_user_subscription_error": "Loading error",
|
"admin_user_subscription_error": "Loading error",
|
||||||
"admin_promo_management_button": "🎟 Promo Management",
|
"admin_promo_management_button": "🎟 Promo Management",
|
||||||
|
|
||||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||||
"admin_promo_management_empty": "📭 No promo codes available",
|
"admin_promo_management_empty": "📭 No promo codes available",
|
||||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||||
@@ -435,5 +453,6 @@
|
|||||||
"admin_ads_delete_button": "🗑 Delete campaign",
|
"admin_ads_delete_button": "🗑 Delete campaign",
|
||||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||||
"admin_ads_deleted_success": "Campaign deleted.",
|
"admin_ads_deleted_success": "Campaign deleted.",
|
||||||
"admin_ads_not_found": "Campaign not found."
|
"admin_ads_not_found": "Campaign not found.",
|
||||||
|
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-10
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Добро пожаловать, {user_name}!",
|
"welcome": "Добро пожаловать, {user_name}!",
|
||||||
|
"channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".",
|
||||||
|
"channel_subscription_join_button": "Перейти в канал",
|
||||||
|
"channel_subscription_verify_button": "Проверить подписку",
|
||||||
|
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||||
|
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||||
|
|
||||||
"menu_activate_trial_button": "🆓 Пробный период",
|
"menu_activate_trial_button": "🆓 Пробный период",
|
||||||
"menu_subscribe_inline": "🚀 Купить",
|
"menu_subscribe_inline": "🚀 Купить",
|
||||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||||
@@ -13,37 +17,53 @@
|
|||||||
"menu_server_status_button": "📊 Статус",
|
"menu_server_status_button": "📊 Статус",
|
||||||
"menu_support_button": "💬 Поддержка",
|
"menu_support_button": "💬 Поддержка",
|
||||||
"menu_terms_button": "📄 Условия сервиса",
|
"menu_terms_button": "📄 Условия сервиса",
|
||||||
|
|
||||||
"back_to_main_menu_button": "⬅️ Назад",
|
"back_to_main_menu_button": "⬅️ Назад",
|
||||||
|
|
||||||
"choose_language": "Выберите язык / Select language:",
|
"choose_language": "Выберите язык / Select language:",
|
||||||
"language_set_alert": "Язык изменен!",
|
"language_set_alert": "Язык изменен!",
|
||||||
|
|
||||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||||
"error_try_again": "Попробуйте еще раз.",
|
"error_try_again": "Попробуйте еще раз.",
|
||||||
"error_displaying_menu": "Ошибка отображения меню.",
|
"error_displaying_menu": "Ошибка отображения меню.",
|
||||||
"main_menu_unknown_action": "Неизвестное действие.",
|
"main_menu_unknown_action": "Неизвестное действие.",
|
||||||
|
|
||||||
"select_subscription_period": "Выберите срок подписки:",
|
"select_subscription_period": "Выберите срок подписки:",
|
||||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||||
|
|
||||||
"choose_payment_method": "Выберите способ оплаты:",
|
"choose_payment_method": "Выберите способ оплаты:",
|
||||||
"pay_button": "💳 Оплатить",
|
"pay_button": "💳 Оплатить",
|
||||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||||
|
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||||
|
"yookassa_autopay_pay_saved_card_button": "💳 Оплата привязанной картой",
|
||||||
|
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
|
||||||
|
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
|
||||||
|
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
|
||||||
|
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||||
|
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||||
|
"pay_with_sbp_button": "📱 СБП",
|
||||||
|
"back_to_payment_methods_button": "⬅️ Назад",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
|
|
||||||
|
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||||
|
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||||
|
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||||
|
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||||
|
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
|
||||||
|
"device_disconnected": "Устройство успешно отключено.",
|
||||||
|
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||||
|
"devices_unlimited_label": "Без ограничений",
|
||||||
|
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||||
|
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||||
|
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||||
|
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||||
"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_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": "недоступна, обратитесь в поддержку",
|
||||||
"traffic_unlimited": "Безлимитный",
|
"traffic_unlimited": "Безлимитный",
|
||||||
|
|
||||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||||
@@ -65,7 +85,6 @@
|
|||||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||||
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
||||||
|
|
||||||
"admin_panel_title": "Панель администратора",
|
"admin_panel_title": "Панель администратора",
|
||||||
"admin_stats_button": "📊 Статистика",
|
"admin_stats_button": "📊 Статистика",
|
||||||
"admin_broadcast_button": "📢 Рассылка",
|
"admin_broadcast_button": "📢 Рассылка",
|
||||||
@@ -131,6 +150,7 @@
|
|||||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||||
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
||||||
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
||||||
|
"broadcast_queue_result": "🚀 Рассылка поставлена в очередь!\n📤 В очередь добавлено: {sent_count}\n❌ Ошибок: {failed_count}\n\n📊 Статус очередей:\n👥 Очередь пользователей: {user_queue_size} сообщений\n📢 Очередь групп: {group_queue_size} сообщений\n\nℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram.",
|
||||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||||
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
||||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||||
@@ -321,7 +341,6 @@
|
|||||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||||
"admin_user_subscription_error": "Ошибка загрузки",
|
"admin_user_subscription_error": "Ошибка загрузки",
|
||||||
"admin_promo_management_button": "🎟 Управление промокодами",
|
"admin_promo_management_button": "🎟 Управление промокодами",
|
||||||
|
|
||||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||||
@@ -434,5 +453,6 @@
|
|||||||
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
||||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||||
"admin_ads_deleted_success": "Кампания удалена.",
|
"admin_ads_deleted_success": "Кампания удалена.",
|
||||||
"admin_ads_not_found": "Кампания не найдена."
|
"admin_ads_not_found": "Кампания не найдена.",
|
||||||
|
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user