Compare commits

...
23 Commits
Author SHA1 Message Date
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
machka paslaandGitHub d672032201 Merge pull request #104 from machka-pasla/dev
Added freekassa, HWID Devices managment, mandatory sub to channel
2025-10-18 22:45:02 +03:00
machka pasla e826c4309d locales 2025-10-18 22:43:38 +03:00
machka pasla 9f525dc7da hwid devices integration 2025-10-18 22:39:56 +03:00
machka paslaandGitHub b880032b9b Merge pull request #103 from gldkru/feature/sub-devices-managment
Add 'My Devices' feature to user subscription management
2025-10-18 21:32:48 +03:00
Kirill Gladkikh 8f9484b6ec Add 'My Devices' feature to user subscription management
- Implemented a new command handler for displaying user devices.
- Added functionality to disconnect devices from the user's account.
- Updated subscription service to retrieve and manage device information.
- Enhanced inline keyboard to include device management options.
- Added new translations for device-related messages in English and Russian locales.
2025-10-18 02:27:19 +03:00
machka pasla c3622d9c2b channel require and db update 2025-10-17 09:36:25 +03:00
machka pasla 51ffbbfa1d pay from saved card 2025-10-16 22:04:04 +03:00
machka paslaandGitHub 394e8dcc6c Merge pull request #100 from zerodata731/freekassa-codex
Freekassa codex
2025-10-14 09:38:47 +03:00
machka paslaandGitHub 5f3fc13c2b Merge branch 'dev' into freekassa-codex 2025-10-14 09:38:39 +03:00
raufakchurin 4d43c9cf0f ORDER ID added to SUccess info 2025-10-14 11:28:41 +05:00
raufakchurin c891122064 before review 2025-10-14 10:31:55 +05:00
raufakchurin 59d07314e2 Удалили все личшние кроме СБП 2025-10-14 09:44:04 +05:00
machka pasla c438672ced fix 2025-10-13 10:16:30 +03:00
machka pasla 49c532e4db bruh 2025-10-13 10:06:58 +03:00
machka paslaandGitHub d50324d098 Merge pull request #99 from zerodata731/freekassa-codex
v_0.0
2025-10-13 10:05:45 +03:00
raufakchurin 52893a0629 v0.1 2025-10-12 15:27:48 +05:00
machka pasla 664cea447c errors when broadcast fix 2025-10-11 16:59:44 +03:00
machka pasla 5f50cfeaee broadcast real time logs 2025-10-11 16:53:26 +03:00
raufakchurin bdade47758 v_0.0 2025-10-11 18:06:49 +05:00
machka paslaandGitHub a3406451cb Merge pull request #98 from machka-pasla/dev
auto renew message with trial fix
2025-10-10 20:05:26 +03:00
machka pasla 9938671af9 auto renew message fix 2025-10-10 20:04:50 +03:00
30 changed files with 2449 additions and 259 deletions
+19 -6
View File
@@ -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
+19 -3
View File
@@ -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: .` для локальной сборки.
+2 -1
View File
@@ -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}
+10 -1
View File
@@ -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,
} }
+7 -1
View File
@@ -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()
+65 -10
View File
@@ -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"),
+4 -3
View File
@@ -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()
+1 -2
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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
+206 -2
View File
@@ -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
+159 -14
View File
@@ -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,
) )
) )
+1
View File
@@ -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",
+138
View File
@@ -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,
)
+3 -1
View File
@@ -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,
+406
View File
@@ -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)
+1
View File
@@ -158,6 +158,7 @@ class NotificationService:
provider_emoji = { provider_emoji = {
"yookassa": "💳", "yookassa": "💳",
"freekassa": "💳",
"cryptopay": "", "cryptopay": "",
"stars": "", "stars": "",
"tribute": "💎" "tribute": "💎"
+43 -7
View File
@@ -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
+1 -1
View File
@@ -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(
+9 -3
View File
@@ -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(
+5 -1
View File
@@ -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:
+10 -2
View File
@@ -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
+68
View File
@@ -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(
+2 -3
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
} }