diff --git a/.env.example b/.env.example
index a22b853..271b9d1 100644
--- a/.env.example
+++ b/.env.example
@@ -20,10 +20,23 @@ TERMS_OF_SERVICE_URL=https://example.com/tos #
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command
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=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_SHOP_ID=your_shop_id # Your store ID in 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_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
CRYPTOPAY_TOKEN= # API token for CryptoPay
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_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.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
diff --git a/README.md b/README.md
index d5d100d..5f2a2e0 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,11 @@
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
+- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
-- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
+- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -27,7 +28,7 @@
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
-- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
+- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
@@ -66,6 +67,9 @@
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `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` |
@@ -81,6 +85,12 @@
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `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`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
@@ -106,6 +116,9 @@
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
+ | `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
+
+ > Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
@@ -125,12 +138,13 @@
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
- Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
+ Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa`
+ - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:/webhook/panel`
@@ -143,6 +157,8 @@
docker compose logs -f remnawave-tg-shop
```
+ > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
+
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
diff --git a/bot/app/controllers/dispatcher_controller.py b/bot/app/controllers/dispatcher_controller.py
index f9de972..1982e95 100644
--- a/bot/app/controllers/dispatcher_controller.py
+++ b/bot/app/controllers/dispatcher_controller.py
@@ -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.action_logger_middleware import ActionLoggerMiddleware
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]:
@@ -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(ProfileSyncMiddleware())
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))
return dp, bot, {"i18n_instance": i18n_instance}
-
diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py
index 68b770b..b261445 100644
--- a/bot/app/factories/build_services.py
+++ b/bot/app/factories/build_services.py
@@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService
+from bot.services.freekassa_service import FreeKassaService
def build_core_services(
@@ -36,6 +37,14 @@ def build_core_services(
subscription_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(
bot,
settings,
@@ -70,9 +79,9 @@ def build_core_services(
"promo_code_service": promo_code_service,
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
+ "freekassa_service": freekassa_service,
"tribute_service": tribute_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
}
-
diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py
index afb2a43..0604ff4 100644
--- a/bot/app/web/web_server.py
+++ b/bot/app/web/web_server.py
@@ -27,6 +27,7 @@ async def build_and_start_web_app(
"referral_service",
"panel_service",
"stars_service",
+ "freekassa_service",
"cryptopay_service",
"tribute_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.crypto_pay_service import cryptopay_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
if tribute_path.startswith("/"):
@@ -61,6 +63,11 @@ async def build_and_start_web_app(
app.router.add_post(cp_path, cryptopay_webhook_route)
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)
yk_path = settings.yookassa_webhook_path
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
await asyncio.Event().wait()
-
diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py
index ce3821d..be425c9 100644
--- a/bot/handlers/admin/broadcast.py
+++ b/bot/handlers/admin/broadcast.py
@@ -334,11 +334,18 @@ async def confirm_broadcast_callback_handler(
await session.rollback()
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()
-
- result_message = (
- _(
+ back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
+ 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",
default=(
"🚀 Рассылка поставлена в очередь!\n"
@@ -350,16 +357,64 @@ async def confirm_broadcast_callback_handler(
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
),
sent_count=sent_count,
- failed_count=failed_count,
- user_queue_size=queue_stats["user_queue_size"],
- group_queue_size=queue_stats["group_queue_size"],
+ failed_count=total_failed,
+ user_queue_size=stats["user_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,
- 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":
await callback.message.edit_text(
_("admin_broadcast_cancelled"),
diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py
index d203e98..19aad73 100644
--- a/bot/handlers/admin/payments.py
+++ b/bot/handlers/admin/payments.py
@@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
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}"
@@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
'yookassa': 'YooKassa',
'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars',
- 'cryptopay': 'CryptoPay'
+ 'cryptopay': 'CryptoPay',
+ 'freekassa': 'FreeKassa',
}.get(payment.provider, payment.provider or 'Unknown')
return (
@@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
@router.callback_query(F.data == "noop")
async def noop_handler(callback: types.CallbackQuery):
"""Handle no-op callback (for pagination display)."""
- await callback.answer()
\ No newline at end of file
+ await callback.answer()
diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py
index 34561c3..726b462 100644
--- a/bot/handlers/admin/statistics.py
+++ b/bot/handlers/admin/statistics.py
@@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"\n{_('admin_stats_recent_payments_header')}")
for payment in last_payments_models:
status_emoji = "✅" if payment.status == 'succeeded' else (
- "⏳" if payment.status == 'pending'
- or payment.status == 'pending_yookassa' else "❌")
+ "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py
index e6bc654..3c3ee9e 100644
--- a/bot/handlers/user/payment.py
+++ b/bot/handlers/user/payment.py
@@ -280,7 +280,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
- user_lang, i18n, settings, config_link
+ user_lang, i18n, settings, config_link, preserve_message=True
)
try:
await bot.send_message(
diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py
index 52f902b..ae4f4e0 100644
--- a/bot/handlers/user/start.py
+++ b/bot/handlers/user/start.py
@@ -7,10 +7,16 @@ from aiogram.fsm.context import FSMContext
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
+from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
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.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
@@ -51,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message,
pass
return
+
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = False
@@ -116,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message,
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(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_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:
pass
+ if not await ensure_required_channel_subscription(message, settings, i18n,
+ current_lang, session,
+ db_user):
+ return
+
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
-
+
# Auto-apply promo code if provided via start parameter
if promo_code_to_apply:
try:
from bot.services.promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
-
+
success, result = await promo_code_service.apply_promo_code(
session, user_id, promo_code_to_apply, current_lang
)
-
+
if success:
await session.commit()
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
-
+
# Get updated subscription details
active = await subscription_service.get_active_subscription_details(session, user_id)
config_link = active.get("config_link") if active else None
config_link = config_link or _("config_link_not_available")
-
+
new_end_date = result if isinstance(result, datetime) else None
-
+
promo_success_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link,
)
-
+
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
await message.answer(
promo_success_text,
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
parse_mode="HTML"
)
-
+
# Don't show main menu if promo was successfully applied
return
else:
await session.rollback()
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
-
+
except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
await session.rollback()
-
+
await send_main_menu(message,
settings,
i18n_data,
@@ -300,6 +490,57 @@ async def start_command_handler(message: types.Message,
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.callback_query(F.data == "main_action:language")
async def language_command_handler(
@@ -404,10 +645,13 @@ async def main_action_callback_handler(
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
elif action == "my_subscription":
-
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
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":
await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session)
@@ -427,6 +671,13 @@ async def main_action_callback_handler(
subscription_service,
session,
is_edit=True)
+ elif action == "back_to_main_keep":
+ await send_main_menu(callback,
+ settings,
+ i18n_data,
+ subscription_service,
+ session,
+ is_edit=False)
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py
index a4582aa..89a6c2c 100644
--- a/bot/handlers/user/subscription/__init__.py
+++ b/bot/handlers/user/subscription/__init__.py
@@ -12,6 +12,6 @@ router.include_router(payments.router)
router.include_router(payment_methods.router)
# 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
diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py
index 5fbd66e..2d7c4de 100644
--- a/bot/handlers/user/subscription/core.py
+++ b/bot/handlers/user/subscription/core.py
@@ -173,6 +173,28 @@ 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)
+ devices_button_text = get_text(
+ "devices_button",
+ current_devices="?",
+ 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)
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
toggle_text = (
@@ -216,6 +238,161 @@ async def my_subscription_command_handler(
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:"))
async def toggle_autorenew_handler(
callback: types.CallbackQuery,
@@ -359,5 +536,3 @@ async def connect_command_handler(
):
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)
-
-
diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py
index 3222a35..d9815cb 100644
--- a/bot/handlers/user/subscription/payments.py
+++ b/bot/handlers/user/subscription/payments.py
@@ -1,11 +1,20 @@
import logging
+from datetime import datetime
from aiogram import Router, F, types
-from typing import Optional
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
-from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
+from bot.keyboards.inline.user_keyboards import (
+ get_payment_method_keyboard,
+ get_payment_url_keyboard,
+ get_yk_autopay_choice_keyboard,
+ get_yk_saved_cards_keyboard,
+ get_back_to_main_menu_markup,
+)
from bot.services.yookassa_service import YooKassaService
+from bot.services.freekassa_service import FreeKassaService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.stars_service import StarsService
from bot.middlewares.i18n import JsonI18n
@@ -14,6 +23,283 @@ from db.dal import payment_dal, user_billing_dal
router = Router(name="user_subscription_payments_router")
+def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]:
+ try:
+ months_str, price_str = payload.split(":")
+ return int(months_str), float(price_str)
+ except (ValueError, IndexError):
+ return None
+
+
+def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
+ def _is_yoomoney_network(name: Optional[str]) -> bool:
+ s = (name or "").lower()
+ return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
+
+ def _extract_last4(text: str) -> Optional[str]:
+ digits = "".join(ch for ch in text if ch.isdigit())
+ return digits[-4:] if len(digits) >= 4 else None
+
+ if _is_yoomoney_network(network):
+ inferred_last4 = last4 or (_extract_last4(network or "") or "****")
+ title = get_text("payment_method_wallet_title", last4=inferred_last4)
+ elif last4:
+ network_name = network or get_text("payment_network_card")
+ title = get_text("payment_method_card_title", network=network_name, last4=last4)
+ else:
+ network_name = network or get_text("payment_network_generic")
+ title = get_text("payment_method_generic_title", network=network_name)
+ return f"⭐ {title}" if is_default else title
+
+
+async def _initiate_yk_payment(
+ callback: types.CallbackQuery,
+ *,
+ settings: Settings,
+ session: AsyncSession,
+ yookassa_service: YooKassaService,
+ i18n: Optional[JsonI18n],
+ current_lang: str,
+ get_text,
+ user_id: int,
+ months: int,
+ price_rub: float,
+ currency_code_for_yk: str,
+ save_payment_method: bool,
+ back_callback: str,
+ payment_method_id: Optional[str] = None,
+ selected_method_internal_id: Optional[int] = None,
+) -> bool:
+ """Create payment record and initiate YooKassa payment (new card or saved card)."""
+ if not callback.message:
+ return False
+
+ payment_description = get_text("payment_description_subscription", months=months)
+ payment_record_data = {
+ "user_id": user_id,
+ "amount": price_rub,
+ "currency": currency_code_for_yk,
+ "status": "pending_yookassa",
+ "description": payment_description,
+ "subscription_duration_months": months,
+ }
+
+ db_payment_record = None
+ try:
+ db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
+ await session.commit()
+ logging.info(
+ f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
+ )
+ except Exception as e_db_payment:
+ await session.rollback()
+ logging.error(
+ f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
+ exc_info=True,
+ )
+ try:
+ await callback.message.edit_text(get_text("error_creating_payment_record"))
+ except Exception:
+ pass
+ return False
+
+ if not db_payment_record:
+ try:
+ await callback.message.edit_text(get_text("error_creating_payment_record"))
+ except Exception:
+ pass
+ return False
+
+ yookassa_metadata = {
+ "user_id": str(user_id),
+ "subscription_months": str(months),
+ "payment_db_id": str(db_payment_record.payment_id),
+ }
+ if payment_method_id:
+ yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
+
+ receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
+
+ payment_response_yk = await yookassa_service.create_payment(
+ amount=price_rub,
+ currency=currency_code_for_yk,
+ description=payment_description,
+ metadata=yookassa_metadata,
+ receipt_email=receipt_email_for_yk,
+ save_payment_method=save_payment_method,
+ payment_method_id=payment_method_id,
+ )
+
+ if payment_response_yk and payment_response_yk.get("confirmation_url"):
+ pm = payment_response_yk.get("payment_method")
+ try:
+ if pm and pm.get("id"):
+ pm_type = pm.get("type")
+ title = pm.get("title")
+ card = pm.get("card") or {}
+ account_number = pm.get("account_number") or pm.get("account")
+ if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
+ display_network = card.get("card_type") or title or "Card"
+ display_last4 = card.get("last4")
+ elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
+ display_network = "YooMoney"
+ display_last4 = (
+ account_number[-4:]
+ if isinstance(account_number, str) and len(account_number) >= 4
+ else None
+ )
+ else:
+ display_network = title or (pm_type.upper() if pm_type else "Payment method")
+ display_last4 = None
+ await user_billing_dal.upsert_yk_payment_method(
+ session,
+ user_id=user_id,
+ payment_method_id=pm["id"],
+ card_last4=display_last4,
+ card_network=display_network,
+ )
+ try:
+ await user_billing_dal.upsert_user_payment_method(
+ session,
+ user_id=user_id,
+ provider_payment_method_id=pm["id"],
+ provider="yookassa",
+ card_last4=display_last4,
+ card_network=display_network,
+ set_default=save_payment_method,
+ )
+ except Exception:
+ pass
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ logging.exception("Failed to save YooKassa payment method preliminarily")
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_db_id=db_payment_record.payment_id,
+ new_status=payment_response_yk.get("status", "pending"),
+ yk_payment_id=payment_response_yk.get("id"),
+ )
+ if selected_method_internal_id is not None:
+ try:
+ await user_billing_dal.set_user_default_payment_method(
+ session, user_id, selected_method_internal_id
+ )
+ except Exception:
+ logging.exception("Failed to set default payment method after initiating payment")
+ await session.commit()
+ except Exception as e_db_update_ykid:
+ await session.rollback()
+ logging.error(
+ f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
+ exc_info=True,
+ )
+ try:
+ await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
+ except Exception:
+ pass
+ return False
+
+ try:
+ await callback.message.edit_text(
+ get_text(key="payment_link_message", months=months),
+ reply_markup=get_payment_url_keyboard(
+ payment_response_yk["confirmation_url"],
+ current_lang,
+ i18n,
+ back_callback=back_callback,
+ back_text_key="back_to_payment_methods_button",
+ ),
+ disable_web_page_preview=False,
+ )
+ except Exception as e_edit:
+ logging.warning(
+ f"Edit message for payment link failed: {e_edit}. Sending new one."
+ )
+ try:
+ await callback.message.answer(
+ get_text(key="payment_link_message", months=months),
+ reply_markup=get_payment_url_keyboard(
+ payment_response_yk["confirmation_url"],
+ current_lang,
+ i18n,
+ back_callback=back_callback,
+ back_text_key="back_to_payment_methods_button",
+ ),
+ disable_web_page_preview=False,
+ )
+ except Exception:
+ pass
+ return True
+
+ if payment_response_yk and payment_method_id:
+ status_to_store = payment_response_yk.get("status", "pending")
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_db_id=db_payment_record.payment_id,
+ new_status=status_to_store,
+ yk_payment_id=payment_response_yk.get("id"),
+ )
+ if selected_method_internal_id is not None:
+ try:
+ await user_billing_dal.set_user_default_payment_method(
+ session, user_id, selected_method_internal_id
+ )
+ except Exception:
+ logging.exception("Failed to set default payment method after saved-card payment start")
+ await session.commit()
+ except Exception as e_db_update_saved:
+ await session.rollback()
+ logging.error(
+ f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
+ exc_info=True,
+ )
+ try:
+ await callback.message.edit_text(get_text("error_payment_gateway"))
+ except Exception:
+ pass
+ return False
+
+ message_text = get_text("yookassa_autopay_charge_initiated")
+ try:
+ await callback.message.edit_text(
+ message_text,
+ reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
+ )
+ except Exception as e_edit:
+ logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
+ try:
+ await callback.message.answer(
+ message_text,
+ reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
+ )
+ except Exception:
+ pass
+ return True
+
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session, db_payment_record.payment_id, "failed_creation"
+ )
+ await session.commit()
+ except Exception as e_db_fail_create:
+ await session.rollback()
+ logging.error(
+ f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
+ exc_info=True,
+ )
+ logging.error(
+ f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
+ )
+ try:
+ await callback.message.edit_text(get_text("error_payment_gateway"))
+ except Exception:
+ pass
+ return False
+
+
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -99,13 +385,457 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
pass
return
+ try:
+ _, data_payload = callback.data.split(":", 1)
+ except ValueError:
+ logging.error(f"Invalid pay_yk data in callback: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ parsed = _parse_months_and_price(data_payload)
+ if not parsed:
+ logging.error(f"Invalid pay_yk payload structure: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ months, price_rub = parsed
+ user_id = callback.from_user.id
+ currency_code_for_yk = "RUB"
+ autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
+ saved_methods: List = []
+ if autopay_enabled:
+ try:
+ saved_methods = await user_billing_dal.list_user_payment_methods(
+ session, user_id, provider="yookassa"
+ )
+ except Exception as e_list:
+ logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
+ saved_methods = []
+
+ if autopay_enabled and saved_methods:
+ try:
+ await callback.message.edit_text(
+ get_text("yookassa_autopay_flow_prompt"),
+ reply_markup=get_yk_autopay_choice_keyboard(
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ has_saved_cards=True,
+ ),
+ )
+ except Exception as e_edit:
+ logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
+ try:
+ await callback.message.answer(
+ get_text("yookassa_autopay_flow_prompt"),
+ reply_markup=get_yk_autopay_choice_keyboard(
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ has_saved_cards=True,
+ ),
+ )
+ except Exception:
+ pass
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+ return
+
+ await _initiate_yk_payment(
+ callback,
+ settings=settings,
+ session=session,
+ yookassa_service=yookassa_service,
+ i18n=i18n,
+ current_lang=current_lang,
+ get_text=get_text,
+ user_id=user_id,
+ months=months,
+ price_rub=price_rub,
+ currency_code_for_yk=currency_code_for_yk,
+ save_payment_method=autopay_enabled,
+ back_callback=f"subscribe_period:{months}",
+ )
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+
+
+@router.callback_query(F.data.startswith("pay_yk_new:"))
+async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
+
+ if not i18n or not callback.message:
+ try:
+ await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ if not yookassa_service or not yookassa_service.configured:
+ logging.error("YooKassa service unavailable for pay_yk_new.")
+ try:
+ await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
+ except Exception:
+ pass
+ try:
+ await callback.message.edit_text(get_text("payment_service_unavailable"))
+ except Exception:
+ pass
+ return
+
+ try:
+ _, data_payload = callback.data.split(":", 1)
+ except ValueError:
+ logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ parsed = _parse_months_and_price(data_payload)
+ if not parsed:
+ logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ months, price_rub = parsed
+ user_id = callback.from_user.id
+ currency_code_for_yk = "RUB"
+ autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
+
+ await _initiate_yk_payment(
+ callback,
+ settings=settings,
+ session=session,
+ yookassa_service=yookassa_service,
+ i18n=i18n,
+ current_lang=current_lang,
+ get_text=get_text,
+ user_id=user_id,
+ months=months,
+ price_rub=price_rub,
+ currency_code_for_yk=currency_code_for_yk,
+ save_payment_method=autopay_enabled,
+ back_callback=f"subscribe_period:{months}",
+ )
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+
+
+@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
+async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
+
+ if not i18n or not callback.message:
+ try:
+ await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
+ if not autopay_enabled:
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ try:
+ _, data_payload = callback.data.split(":", 1)
+ except ValueError:
+ logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ parts = data_payload.split(":")
+ if len(parts) < 2:
+ logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ try:
+ months = int(parts[0])
+ price_rub = float(parts[1])
+ page = int(parts[2]) if len(parts) > 2 else 0
+ except (ValueError, IndexError):
+ logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ user_id = callback.from_user.id
+ try:
+ saved_methods = await user_billing_dal.list_user_payment_methods(
+ session, user_id, provider="yookassa"
+ )
+ except Exception as e_list:
+ logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
+ saved_methods = []
+
+ if not saved_methods:
+ try:
+ await callback.message.edit_text(
+ get_text("yookassa_autopay_no_saved_cards"),
+ reply_markup=get_yk_autopay_choice_keyboard(
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ has_saved_cards=False,
+ ),
+ )
+ except Exception as e_edit:
+ logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
+ try:
+ await callback.message.answer(
+ get_text("yookassa_autopay_no_saved_cards"),
+ reply_markup=get_yk_autopay_choice_keyboard(
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ has_saved_cards=False,
+ ),
+ )
+ except Exception:
+ pass
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+ return
+
+ cards: List[Tuple[str, str]] = []
+ for method in saved_methods:
+ title = _format_saved_payment_method_title(
+ get_text, method.card_network, method.card_last4, method.is_default
+ )
+ cards.append((str(method.method_id), title))
+
+ per_page = 5
+ max_page = max(0, (len(cards) - 1) // per_page)
+ page = max(0, min(page, max_page))
+
+ try:
+ await callback.message.edit_text(
+ get_text("yookassa_autopay_choose_saved_card"),
+ reply_markup=get_yk_saved_cards_keyboard(
+ cards,
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ page=page,
+ ),
+ )
+ except Exception as e_edit:
+ logging.warning(f"Failed to display saved card list: {e_edit}")
+ try:
+ await callback.message.answer(
+ get_text("yookassa_autopay_choose_saved_card"),
+ reply_markup=get_yk_saved_cards_keyboard(
+ cards,
+ months,
+ price_rub,
+ current_lang,
+ i18n,
+ page=page,
+ ),
+ )
+ except Exception:
+ pass
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+
+
+@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
+async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
+
+ if not i18n or not callback.message:
+ try:
+ await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
+ if not autopay_enabled:
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ if not yookassa_service or not yookassa_service.configured:
+ logging.error("YooKassa service unavailable for pay_yk_use_saved.")
+ try:
+ await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
+ except Exception:
+ pass
+ try:
+ await callback.message.edit_text(get_text("payment_service_unavailable"))
+ except Exception:
+ pass
+ return
+
+ try:
+ _, data_payload = callback.data.split(":", 1)
+ except ValueError:
+ logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ parts = data_payload.split(":")
+ if len(parts) < 3:
+ logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ try:
+ months = int(parts[0])
+ price_rub = float(parts[1])
+ except (ValueError, IndexError):
+ logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ method_identifier = parts[2]
+ user_id = callback.from_user.id
+
+ try:
+ saved_methods = await user_billing_dal.list_user_payment_methods(
+ session, user_id, provider="yookassa"
+ )
+ except Exception as e_list:
+ logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
+ saved_methods = []
+
+ selected_method = None
+ for method in saved_methods:
+ if method_identifier.isdigit():
+ if method.method_id == int(method_identifier):
+ selected_method = method
+ break
+ if method.provider_payment_method_id == method_identifier:
+ selected_method = method
+ break
+
+ if not selected_method:
+ logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
+ try:
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ currency_code_for_yk = "RUB"
+
+ await _initiate_yk_payment(
+ callback,
+ settings=settings,
+ session=session,
+ yookassa_service=yookassa_service,
+ i18n=i18n,
+ current_lang=current_lang,
+ get_text=get_text,
+ user_id=user_id,
+ months=months,
+ price_rub=price_rub,
+ currency_code_for_yk=currency_code_for_yk,
+ save_payment_method=False,
+ back_callback=f"pay_yk_saved_list:{months}:{price_rub}",
+ payment_method_id=selected_method.provider_payment_method_id,
+ selected_method_internal_id=selected_method.method_id,
+ )
+ try:
+ await callback.answer()
+ except Exception:
+ pass
+
+
+@router.callback_query(F.data.startswith("pay_fk:"))
+async def pay_fk_callback_handler(
+ callback: types.CallbackQuery,
+ settings: Settings,
+ i18n_data: dict,
+ freekassa_service: FreeKassaService,
+ session: AsyncSession,
+):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
+
+ if not i18n or not callback.message:
+ try:
+ await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
+ except Exception:
+ pass
+ return
+
+ if not freekassa_service or not freekassa_service.configured:
+ logging.error("FreeKassa service is not configured or unavailable.")
+ try:
+ await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
+ except Exception:
+ pass
+ try:
+ await callback.message.edit_text(get_text("payment_service_unavailable"))
+ except Exception:
+ pass
+ return
+
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_rub = float(price_str)
except (ValueError, IndexError):
- logging.error(f"Invalid pay_yk data in callback: {callback.data}")
+ logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
@@ -114,149 +844,142 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
- currency_code_for_yk = "RUB"
+ currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
- payment_record_data = {
+ payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
- "currency": currency_code_for_yk,
- "status": "pending_yookassa",
+ "currency": currency_code,
+ "status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": months,
+ "provider": "freekassa",
}
- db_payment_record = None
try:
- db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
+ payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
- logging.info(
- f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
- )
- except Exception as e_db_payment:
+ except Exception as e_db_create:
await session.rollback()
logging.error(
- f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
+ f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
- await callback.message.edit_text(get_text("error_creating_payment_record"))
+ try:
+ await callback.message.edit_text(get_text("error_creating_payment_record"))
+ except Exception:
+ pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
- if not db_payment_record:
- await callback.message.edit_text(get_text("error_creating_payment_record"))
- try:
- await callback.answer(get_text("error_try_again"), show_alert=True)
- except Exception:
- pass
- return
-
- yookassa_metadata = {
- "user_id": str(user_id),
- "subscription_months": str(months),
- "payment_db_id": str(db_payment_record.payment_id),
- }
- receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
-
- payment_response_yk = await yookassa_service.create_payment(
+ success, response_data = await freekassa_service.create_order(
+ payment_db_id=payment_record.payment_id,
+ user_id=payment_record.user_id,
+ months=months,
amount=price_rub,
- currency=currency_code_for_yk,
- description=payment_description,
- metadata=yookassa_metadata,
- receipt_email=receipt_email_for_yk,
- # Save method only when autopayments are enabled
- save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)),
+ currency=freekassa_service.default_currency,
+ method_code=44,
+ ip_address=freekassa_service.server_ip,
+ extra_params={
+ "us_method": 44,
+ },
)
- if payment_response_yk and payment_response_yk.get("confirmation_url"):
- pm = payment_response_yk.get("payment_method")
- try:
- if pm and pm.get("id"):
- pm_type = pm.get("type")
- title = pm.get("title")
- card = pm.get("card") or {}
- account_number = pm.get("account_number") or pm.get("account")
- if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
- display_network = card.get("card_type") or title or "Card"
- display_last4 = card.get("last4")
- elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
- display_network = "YooMoney"
- display_last4 = (
- account_number[-4:]
- if isinstance(account_number, str) and len(account_number) >= 4
- else None
- )
- else:
- display_network = title or (pm_type.upper() if pm_type else "Payment method")
- display_last4 = None
- await user_billing_dal.upsert_yk_payment_method(
+ if success:
+ location = response_data.get("location")
+ order_hash = response_data.get("orderHash")
+ order_id_api = response_data.get("orderId")
+ provider_identifier = order_hash or order_id_api
+
+ if provider_identifier:
+ try:
+ await payment_dal.update_provider_payment_and_status(
session,
- user_id=user_id,
- payment_method_id=pm["id"],
- card_last4=display_last4,
- card_network=display_network,
+ payment_record.payment_id,
+ str(provider_identifier),
+ payment_record.status,
)
+ await session.commit()
+ except Exception as e_status:
+ await session.rollback()
+ logging.error(
+ f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
+ exc_info=True,
+ )
+
+ if location:
+ order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
+ order_info_text = get_text(
+ "free_kassa_order_info",
+ order_id=order_identifier_display,
+ date=datetime.now().strftime("%Y-%m-%d"),
+ )
+ try:
+ await callback.message.edit_text(
+ f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months),
+ reply_markup=get_payment_url_keyboard(
+ location,
+ current_lang,
+ i18n,
+ back_callback=f"subscribe_period:{months}",
+ back_text_key="back_to_payment_methods_button",
+ ),
+ disable_web_page_preview=False,
+ )
+ except Exception as e_edit:
+ logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
try:
- await user_billing_dal.upsert_user_payment_method(
- session,
- user_id=user_id,
- provider_payment_method_id=pm["id"],
- provider="yookassa",
- card_last4=display_last4,
- card_network=display_network,
- set_default=True,
+ await callback.message.answer(
+ f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months),
+ reply_markup=get_payment_url_keyboard(
+ location,
+ current_lang,
+ i18n,
+ back_callback=f"subscribe_period:{months}",
+ back_text_key="back_to_payment_methods_button",
+ ),
+ disable_web_page_preview=False,
)
except Exception:
pass
- await session.commit()
- except Exception:
- await session.rollback()
- logging.exception("Failed to save YooKassa payment method preliminarily")
- try:
- await payment_dal.update_payment_status_by_db_id(
- session,
- payment_db_id=db_payment_record.payment_id,
- new_status=payment_response_yk.get("status", "pending"),
- yk_payment_id=payment_response_yk.get("id"),
- )
- await session.commit()
- except Exception as e_db_update_ykid:
- await session.rollback()
- logging.error(
- f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
- exc_info=True,
- )
- await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
try:
- await callback.answer(get_text("error_try_again"), show_alert=True)
+ await callback.answer()
except Exception:
pass
return
- await callback.message.edit_text(
- get_text(key="payment_link_message", months=months),
- reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n),
- disable_web_page_preview=False,
+ logging.error(
+ "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
+ payment_record.payment_id,
+ response_data,
)
else:
- try:
- await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation")
- await session.commit()
- except Exception as e_db_fail_create:
- await session.rollback()
- logging.error(
- f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
- exc_info=True,
- )
logging.error(
- f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
+ "FreeKassa: create_order failed for payment %s with response %s",
+ payment_record.payment_id,
+ response_data,
)
- await callback.message.edit_text(get_text("error_payment_gateway"))
try:
- await callback.answer()
+ await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_record.payment_id,
+ "failed_creation",
+ )
+ await session.commit()
+ except Exception as e_status:
+ await session.rollback()
+ logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
+
+ try:
+ await callback.message.edit_text(get_text("error_payment_gateway"))
+ except Exception:
+ pass
+ try:
+ await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -314,14 +1037,26 @@ async def pay_crypto_callback_handler(
try:
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
- reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
+ reply_markup=get_payment_url_keyboard(
+ invoice_url,
+ current_lang,
+ i18n,
+ back_callback=f"subscribe_period:{months}",
+ back_text_key="back_to_payment_methods_button",
+ ),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(key="payment_link_message", months=months),
- reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
+ reply_markup=get_payment_url_keyboard(
+ invoice_url,
+ current_lang,
+ i18n,
+ back_callback=f"subscribe_period:{months}",
+ back_text_key="back_to_payment_methods_button",
+ ),
disable_web_page_preview=False,
)
except Exception:
@@ -388,6 +1123,18 @@ async def pay_stars_callback_handler(
)
if payment_db_id:
+ try:
+ await callback.message.edit_text(
+ get_text("payment_invoice_sent_message", months=months),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(
+ text=get_text("back_to_payment_methods_button"),
+ callback_data=f"subscribe_period:{months}",
+ )]
+ ]),
+ )
+ except Exception as e_edit:
+ logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
@@ -435,4 +1182,3 @@ async def handle_successful_stars_payment(
stars_amount=stars_amount,
i18n_data=i18n_data,
)
-
diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py
index 456eae4..96a43a2 100644
--- a/bot/keyboards/inline/user_keyboards.py
+++ b/bot/keyboards/inline/user_keyboards.py
@@ -118,14 +118,17 @@ def get_payment_method_keyboard(months: int, price: float,
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
- 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.TRIBUTE_ENABLED and tribute_url:
- builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
+ if settings.FREEKASSA_ENABLED:
+ builder.button(text=_("pay_with_sbp_button"),
+ callback_data=f"pay_fk:{months}:{price}")
if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"),
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:
builder.button(text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{months}:{price}")
@@ -135,17 +138,115 @@ def get_payment_method_keyboard(months: int, price: float,
return builder.as_markup()
-def get_payment_url_keyboard(payment_url: str, lang: str,
- i18n_instance) -> InlineKeyboardMarkup:
+def get_payment_url_keyboard(payment_url: str,
+ 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)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="pay_button"), url=payment_url)
- builder.button(text=_(key="back_to_main_menu_button"),
- callback_data="main_action:back_to_main")
+ if back_callback:
+ 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)
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,
i18n_instance) -> InlineKeyboardMarkup:
_ = 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,
- i18n_instance) -> InlineKeyboardMarkup:
+ i18n_instance,
+ callback_data: Optional[str] = None) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
- builder.button(text=_(key="back_to_main_menu_button"),
- callback_data="main_action:back_to_main")
+ if callback_data:
+ 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()
@@ -185,11 +291,49 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
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(
lang: str,
i18n_instance,
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."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
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(
InlineKeyboardButton(
text=_("back_to_main_menu_button"),
- callback_data="main_action:back_to_main",
+ callback_data=back_callback,
)
)
diff --git a/bot/main_bot.py b/bot/main_bot.py
index e25a0dc..0a7fa10 100644
--- a/bot/main_bot.py
+++ b/bot/main_bot.py
@@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
for service_key in (
"panel_service",
"cryptopay_service",
+ "freekassa_service",
"tribute_service",
"panel_webhook_service",
"yookassa_service",
diff --git a/bot/middlewares/channel_subscription.py b/bot/middlewares/channel_subscription.py
new file mode 100644
index 0000000..3af434b
--- /dev/null
+++ b/bot/middlewares/channel_subscription.py
@@ -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,
+ )
diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py
index 7cf8baf..b846438 100644
--- a/bot/services/crypto_pay_service.py
+++ b/bot/services/crypto_pay_service.py
@@ -209,7 +209,9 @@ class CryptoPayService:
end_date=final_end.strftime('%Y-%m-%d'),
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:
await bot.send_message(
user_id,
diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py
new file mode 100644
index 0000000..662b379
--- /dev/null
+++ b/bot/services/freekassa_service.py
@@ -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)
diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py
index 9a774b8..24f1a39 100644
--- a/bot/services/notification_service.py
+++ b/bot/services/notification_service.py
@@ -158,6 +158,7 @@ class NotificationService:
provider_emoji = {
"yookassa": "💳",
+ "freekassa": "💳",
"cryptopay": "₿",
"stars": "⭐",
"tribute": "💎"
diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py
index 2f036b1..60586d0 100644
--- a/bot/services/panel_api_service.py
+++ b/bot/services/panel_api_service.py
@@ -21,11 +21,11 @@ class PanelApiService:
self.api_key = settings.PANEL_API_KEY
self._session: Optional[aiohttp.ClientSession] = None
self.default_client_ip = "127.0.0.1"
-
+
async def __aenter__(self):
"""Context manager entry"""
return self
-
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - automatically close session"""
await self.close_session()
@@ -337,6 +337,7 @@ class PanelApiService:
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
default_traffic_limit_strategy: str = "NO_RESET",
+ hwid_device_limit: Optional[int] = None,
specific_squad_uuids: Optional[List[str]] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
@@ -368,6 +369,18 @@ class PanelApiService:
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
"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:
payload["activeInternalSquads"] = specific_squad_uuids
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 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,
session: AsyncSession,
status: str,
@@ -468,25 +505,24 @@ class PanelApiService:
async def get_bot_db_last_sync_status(
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
return await panel_sync_dal.get_panel_sync_status(session)
-
-
+
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
"""Get system statistics (CPU, memory, users counts)"""
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:
return response_data.get("response")
return None
-
+
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
"""Get bandwidth statistics"""
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:
return response_data.get("response")
return None
-
+
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
"""Get nodes statistics"""
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:
return response_data.get("response")
- return None
\ No newline at end of file
+ return None
diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py
index 17173a2..e278796 100644
--- a/bot/services/stars_service.py
+++ b/bot/services/stars_service.py
@@ -148,7 +148,7 @@ class StarsService:
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
- current_lang, i18n, self.settings, config_link
+ current_lang, i18n, self.settings, config_link, preserve_message=True
)
try:
await self.bot.send_message(
diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py
index eb71475..73fe993 100644
--- a/bot/services/subscription_service.py
+++ b/bot/services/subscription_service.py
@@ -593,10 +593,10 @@ class SubscriptionService:
)
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
-
+
# 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
-
+
bonus_sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_uuid,
@@ -635,7 +635,7 @@ class SubscriptionService:
),
include_uuid=False,
)
-
+
panel_update_success = (
await self.panel_service.update_user_details_on_panel(
panel_uuid,
@@ -739,8 +739,12 @@ class SubscriptionService:
if panel_user_data.get("expireAt")
else None
)
+ hwid_limit = panel_user_data.get("hwidDeviceLimit")
+ if hwid_limit is None:
+ hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
return {
+ "user_id": panel_user_data.get("uuid"),
"end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": panel_user_data.get("subscriptionUrl"),
@@ -748,6 +752,7 @@ class SubscriptionService:
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
"user_bot_username": db_user.username,
"is_panel_data": True,
+ "max_devices": hwid_limit,
}
async def get_subscriptions_ending_soon(
diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py
index de12e55..0e48ce7 100644
--- a/bot/services/tribute_service.py
+++ b/bot/services/tribute_service.py
@@ -208,7 +208,11 @@ class TributeService:
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
- lang, i18n, settings, config_link
+ lang,
+ i18n,
+ settings,
+ config_link,
+ preserve_message=True,
)
try:
diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py
index 40e3c0b..0f9314c 100644
--- a/bot/utils/message_queue.py
+++ b/bot/utils/message_queue.py
@@ -26,6 +26,8 @@ class MessageQueue:
self.last_send_times: deque[datetime] = deque()
self.is_processing = False
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:
"""Add message to queue"""
@@ -50,6 +52,7 @@ class MessageQueue:
try:
await self._send_message(message)
self.last_send_times.append(datetime.now())
+ self.total_sent += 1
# Keep only recent send times (last minute)
cutoff_time = datetime.now() - timedelta(seconds=60)
@@ -57,6 +60,7 @@ class MessageQueue:
self.last_send_times.popleft()
except Exception as e:
+ self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
finally:
@@ -233,7 +237,11 @@ class MessageQueueManager:
"group_queue_processing": self.group_queue.is_processing,
"user_queue_processing": self.user_queue.is_processing,
"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]:
"""Get global queue manager instance"""
- return _queue_manager
\ No newline at end of file
+ return _queue_manager
diff --git a/config/settings.py b/config/settings.py
index 21878b4..f7a86a9 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -23,6 +23,12 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_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_SECRET_KEY: Optional[str] = None
@@ -44,6 +50,14 @@ class Settings(BaseSettings):
CRYPTOPAY_ASSET: str = Field(default="RUB")
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)
STARS_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)
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_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 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_field
@property
@@ -331,6 +367,22 @@ class Settings(BaseSettings):
if isinstance(v, str) and v.strip() == '':
return None
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
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
@@ -366,6 +418,22 @@ def get_settings() -> Settings:
logging.warning(
"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:
logging.critical(
diff --git a/db/database_setup.py b/db/database_setup.py
index 4c1fbf8..711c11b 100644
--- a/db/database_setup.py
+++ b/db/database_setup.py
@@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from .models import Base
-from .migrator import run_simple_migrations
+from .migrator import run_database_migrations
async_engine = None
@@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
- # Run lightweight, idempotent migrations to add any missing columns
- await conn.run_sync(run_simple_migrations)
+ await conn.run_sync(run_database_migrations)
logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
diff --git a/db/migrator.py b/db/migrator.py
index 3f37cc0..e91cb53 100644
--- a/db/migrator.py
+++ b/db/migrator.py
@@ -1,66 +1,98 @@
import logging
-from typing import Set
+from dataclasses import dataclass
+from typing import Callable, List, Set
from sqlalchemy import inspect, text
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)
- 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():
- table_name = table.name
- if table_name not in existing_tables:
- # Tables are created elsewhere via create_all; skip here.
+ for stmt in statements:
+ connection.execute(text(stmt))
+
+
+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
- existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
-
- for desired_column in table.columns:
- if desired_column.name in existing_columns:
- continue
-
- # Build ADD COLUMN DDL
- preparer = connection.dialect.identifier_preparer
- table_quoted = preparer.format_table(table)
- column_name_quoted = preparer.quote(desired_column.name)
- column_type_sql = desired_column.type.compile(dialect=connection.dialect)
-
- default_clause = ""
- server_default = getattr(desired_column, "server_default", None)
- if server_default is not None and getattr(server_default, "arg", None) is not None:
- try:
- compiled_default = str(
- server_default.arg.compile(dialect=connection.dialect)
- )
- 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}"
+ logging.info(
+ "Migrator: applying %s – %s", migration.id, migration.description
+ )
+ try:
+ with connection.begin_nested():
+ migration.upgrade(connection)
+ connection.execute(
+ text(
+ "INSERT INTO schema_migrations (id) VALUES (:revision)"
+ ),
+ {"revision": migration.id},
+ )
+ except Exception as exc:
+ logging.error(
+ "Migrator: failed to apply %s (%s)",
+ migration.id,
+ migration.description,
+ exc_info=True,
)
- connection.execute(text(ddl))
-
-
-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
+ raise exc
+ else:
+ logging.info("Migrator: migration %s applied successfully", migration.id)
diff --git a/db/models.py b/db/models.py
index d33dfdf..5e8d079 100644
--- a/db/models.py
+++ b/db/models.py
@@ -24,6 +24,10 @@ class User(Base):
referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"),
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")
subscriptions = relationship("Subscription",
diff --git a/locales/en.json b/locales/en.json
index 6d6b0a3..46071a4 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -1,11 +1,15 @@
{
"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?",
-
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
"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",
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
"menu_apply_promo_button": "🎟 Promo Code",
@@ -13,37 +17,53 @@
"menu_server_status_button": "📊 Status",
"menu_support_button": "💬 Support",
"menu_terms_button": "📄 Terms of Service",
-
"back_to_main_menu_button": "⬅️ Back",
-
"choose_language": "Choose language:",
"language_set_alert": "Language changed!",
-
"error_occurred_try_again": "An error occurred, please try again.",
"error_try_again": "Please try again.",
"error_displaying_menu": "Error displaying menu.",
"main_menu_unknown_action": "Unknown action.",
-
"select_subscription_period": "Select subscription period:",
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
-
"choose_payment_method": "Choose payment method:",
"pay_button": "💳 Pay",
"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_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"cancel_button": "❌ Cancel",
+
+ "devices_button": "📱 My Devices ({current_devices}/{max_devices})",
+ "my_devices_details": "📱 My Devices ({current_devices}/{max_devices})\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\nNote: 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.
",
+ "no_devices_details_found_message": "📱 My Devices\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}) {device_model} ({platform} {os_version})\nDate connected: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}",
+ "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_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_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n{config_link}\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{config_link}\n\nTo connect, open the link and follow the instructions 👇",
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"config_link_not_available": "not available, contact support",
"traffic_unlimited": "Unlimited",
-
"promo_code_prompt": "Please enter your promo code:",
"promo_code_not_found": "Promo 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}.",
@@ -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_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.",
-
"admin_panel_title": "Admin Panel",
"admin_stats_button": "📊 Statistics",
"admin_broadcast_button": "📢 Broadcast",
@@ -322,7 +341,6 @@
"admin_user_subscription_active_until": "⏰ Active until:",
"admin_user_subscription_error": "Loading error",
"admin_promo_management_button": "🎟 Promo Management",
-
"admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 Promo Code: {code}",
@@ -435,5 +453,6 @@
"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_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"
}
diff --git a/locales/ru.json b/locales/ru.json
index 686bc23..77c8475 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1,7 +1,11 @@
{
"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Что бы вы хотели сделать?",
-
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
"menu_my_subscription_inline": "🔐 Моя подписка",
@@ -13,37 +17,53 @@
"menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса",
-
"back_to_main_menu_button": "⬅️ Назад",
-
"choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!",
-
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
"error_try_again": "Попробуйте еще раз.",
"error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.",
-
"select_subscription_period": "Выберите срок подписки:",
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
-
"choose_payment_method": "Выберите способ оплаты:",
"pay_button": "💳 Оплатить",
"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_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
+
+ "devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
+ "my_devices_details": "📱 Список ваших устройств ({current_devices}/{max_devices})\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\nПримечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.
",
+ "no_devices_details_found_message": "📱 Список ваших устройств\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
+ "device_details": "{index}) {device_model} ({platform} {os_version})\nДата подключения: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}",
+ "disconnect_device_button": "🚫 Отключить {hwid} ({index})",
+ "device_disconnected": "Устройство успешно отключено.",
+ "no_devices_found": "Ошибка загрузки списка устройств.",
+ "devices_unlimited_label": "Без ограничений",
+ "my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
+
"cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
+ "free_kassa_order_info": "Заказ №{order_id} от {date}",
+ "payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"config_link_not_available": "недоступна, обратитесь в поддержку",
"traffic_unlimited": "Безлимитный",
-
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
"promo_code_not_found": "Промокод {code} не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод {code}.",
@@ -65,7 +85,6 @@
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
-
"admin_panel_title": "Панель администратора",
"admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка",
@@ -131,6 +150,7 @@
"admin_broadcast_cancelled": "Рассылка отменена.",
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
"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_bonus_days": "Количество бонусных дней должно быть положительным числом.",
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
@@ -321,7 +341,6 @@
"admin_user_subscription_active_until": "⏰ Действует до:",
"admin_user_subscription_error": "Ошибка загрузки",
"admin_promo_management_button": "🎟 Управление промокодами",
-
"admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 Промокод: {code}",
@@ -434,5 +453,6 @@
"admin_ads_delete_button": "🗑 Удалить кампанию",
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
"admin_ads_deleted_success": "Кампания удалена.",
- "admin_ads_not_found": "Кампания не найдена."
+ "admin_ads_not_found": "Кампания не найдена.",
+ "free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
}