@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.2"
|
||||
ARG VERSION="v2.5.3"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1309,12 +1309,10 @@ async def activate_pending_subscription(
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
if pending_subscription.end_date <= current_time:
|
||||
# Если текущая дата окончания уже прошла, устанавливаем новую
|
||||
pending_subscription.end_date = current_time + timedelta(days=period_days)
|
||||
else:
|
||||
# Если дата окончания в будущем, продляем её
|
||||
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
|
||||
effective_start = pending_subscription.start_date or current_time
|
||||
if effective_start < current_time:
|
||||
effective_start = current_time
|
||||
pending_subscription.end_date = effective_start + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting, User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -30,6 +35,7 @@ from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
|
||||
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"core": {
|
||||
@@ -61,9 +67,17 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "📅 Подписки и цены",
|
||||
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
|
||||
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
|
||||
"icon": "📅",
|
||||
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
|
||||
"categories": (
|
||||
"SUBSCRIPTIONS_CORE",
|
||||
"SIMPLE_SUBSCRIPTION",
|
||||
"PERIODS",
|
||||
"SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC",
|
||||
"TRAFFIC_PACKAGES",
|
||||
"AUTOPAY",
|
||||
),
|
||||
},
|
||||
"trial": {
|
||||
"title": "🎁 Пробный период",
|
||||
@@ -1417,6 +1431,16 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Выбрать сквад",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1645,6 +1669,217 @@ async def show_bot_config_category(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_simple_subscription_squad_selector(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 5)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = max(1, int(parts[5])) if len(parts) > 5 else 1
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
total_count = total_count or 0
|
||||
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
|
||||
if total_count and page > total_pages:
|
||||
page = total_pages
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
current_uuid = bot_configuration_service.get_current_value(key) or ""
|
||||
current_display = "Любой доступный"
|
||||
|
||||
if current_uuid:
|
||||
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, current_uuid)
|
||||
if selected_server:
|
||||
current_display = selected_server.display_name
|
||||
else:
|
||||
current_display = current_uuid
|
||||
|
||||
lines = [
|
||||
"🌍 <b>Выберите сквад для простой покупки</b>",
|
||||
"",
|
||||
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
|
||||
"",
|
||||
]
|
||||
|
||||
if total_count == 0:
|
||||
lines.append("❌ Доступные сервера не найдены.")
|
||||
else:
|
||||
lines.append("Выберите сервер из списка ниже.")
|
||||
if total_pages > 1:
|
||||
lines.append(f"Страница {page}/{total_pages}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
|
||||
for server in squads:
|
||||
status_icon = (
|
||||
"✅" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
|
||||
)
|
||||
label_parts = [status_icon, server.display_name]
|
||||
if server.country_code:
|
||||
label_parts.append(f"({server.country_code.upper()})")
|
||||
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
|
||||
try:
|
||||
label_parts.append(f"— {settings.format_price(server.price_kopeks)}")
|
||||
except Exception:
|
||||
pass
|
||||
label = " ".join(label_parts)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: List[types.InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if nav_row:
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=(
|
||||
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_simple_subscription_squad(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 6)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
try:
|
||||
server_id = int(parts[5]) if len(parts) > 5 else None
|
||||
except ValueError:
|
||||
server_id = None
|
||||
|
||||
if server_id is None:
|
||||
await callback.answer("Не удалось определить сервер", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, server.squad_uuid)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await _store_setting_context(
|
||||
state,
|
||||
key=key,
|
||||
group_key=group_key,
|
||||
category_page=category_page,
|
||||
settings_page=settings_page,
|
||||
)
|
||||
await callback.answer("Сквад выбран")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def test_remnawave_connection(
|
||||
@@ -2519,6 +2754,14 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
test_payment_provider,
|
||||
F.data.startswith("botcfg_test_payment:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_simple_subscription_squad,
|
||||
F.data.startswith("botcfg_simple_squad_select:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_simple_subscription_squad_selector,
|
||||
F.data.startswith("botcfg_simple_squad:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_bot_config_setting,
|
||||
F.data.startswith("botcfg_setting:"),
|
||||
@@ -2557,4 +2800,3 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Обработчики для простой покупки подписки."""
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -32,14 +35,10 @@ async def start_simple_subscription_purchase(
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя активная подписка
|
||||
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and current_subscription.is_active:
|
||||
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
@@ -91,6 +90,18 @@ async def start_simple_subscription_purchase(
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
trial_notice = ""
|
||||
if current_subscription and getattr(current_subscription, "is_trial", False):
|
||||
try:
|
||||
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
|
||||
except Exception:
|
||||
days_left = 0
|
||||
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
|
||||
trial_notice = texts.t(
|
||||
key,
|
||||
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
@@ -106,6 +117,9 @@ async def start_simple_subscription_purchase(
|
||||
)
|
||||
)
|
||||
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
|
||||
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
|
||||
@@ -768,9 +782,112 @@ async def handle_simple_subscription_payment_method(
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата через CryptoBot временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через CryptoBot
|
||||
await callback.answer("❌ Оплата через CryptoBot пока не реализована", show_alert=True)
|
||||
|
||||
amount_rubles = price_kopeks / 100
|
||||
if amount_rubles < 100 or amount_rubles > 100000:
|
||||
await callback.answer(
|
||||
"❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через CryptoBot",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
usd_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception as rate_error:
|
||||
logger.warning("Не удалось получить курс USD: %s", rate_error)
|
||||
usd_rate = 95.0
|
||||
|
||||
amount_usd = round(amount_rubles / usd_rate, 2)
|
||||
if amount_usd < 1:
|
||||
await callback.answer(
|
||||
"❌ Минимальная сумма для оплаты через CryptoBot — примерно 1 USD",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if amount_usd > 1000:
|
||||
await callback.answer(
|
||||
"❌ Максимальная сумма для оплаты через CryptoBot — 1000 USD",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
crypto_result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_subscription_payment_description(
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
),
|
||||
payload=f"simple_subscription_{db_user.id}_{price_kopeks}",
|
||||
)
|
||||
|
||||
if not crypto_result:
|
||||
await callback.answer(
|
||||
"❌ Ошибка создания платежа через CryptoBot. Попробуйте позже или обратитесь в поддержку.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = (
|
||||
crypto_result.get("mini_app_invoice_url")
|
||||
or crypto_result.get("bot_invoice_url")
|
||||
or crypto_result.get("web_app_invoice_url")
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer(
|
||||
"❌ Не удалось получить ссылку для оплаты. Обратитесь в поддержку.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🪙 Оплатить через CryptoBot",
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_cryptobot_{crypto_result['local_payment_id']}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
message_text = (
|
||||
"🪙 <b>Оплата через CryptoBot</b>\n\n"
|
||||
f"💰 Сумма к оплате: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 В долларах: {amount_usd:.2f} USD\n"
|
||||
f"🪙 Актив: {crypto_result['asset']}\n"
|
||||
f"💱 Курс: 1 USD ≈ {usd_rate:.2f} ₽\n"
|
||||
f"🆔 ID платежа: {crypto_result['invoice_id'][:8]}...\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через CryptoBot'\n"
|
||||
"2. Выберите актив и следуйте подсказкам\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
elif payment_method == "mulenpay":
|
||||
# Оплата через MulenPay
|
||||
@@ -782,29 +899,357 @@ async def handle_simple_subscription_payment_method(
|
||||
)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через MulenPay
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} пока не реализована",
|
||||
show_alert=True,
|
||||
if price_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS or price_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
"❌ Сумма для Mulen Pay должна быть в пределах от {min_amount} до {max_amount}".format(
|
||||
min_amount=settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS),
|
||||
max_amount=settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS),
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
mulen_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=settings.get_subscription_payment_description(
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not mulen_result or not mulen_result.get("payment_url"):
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = mulen_result["payment_url"]
|
||||
local_payment_id = mulen_result.get("local_payment_id")
|
||||
payment_id_display = mulen_result.get("mulen_payment_id") or local_payment_id
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MULENPAY_PAY_BUTTON", "💳 Оплатить через Mulen Pay"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_template.format(
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=settings.get_mulenpay_display_name_html(),
|
||||
amount=settings.format_price(price_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
elif payment_method == "pal24":
|
||||
# Оплата через PayPalych
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через PayPalych
|
||||
await callback.answer("❌ Оплата через PayPalych пока не реализована", show_alert=True)
|
||||
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
pal24_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=settings.get_subscription_payment_description(
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not pal24_result:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
sbp_url = pal24_result.get("sbp_url") or pal24_result.get("transfer_url")
|
||||
card_url = pal24_result.get("card_url")
|
||||
fallback_url = pal24_result.get("link_page_url") or pal24_result.get("link_url")
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = pal24_result.get("bill_id")
|
||||
local_payment_id = pal24_result.get("local_payment_id")
|
||||
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(price_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
elif payment_method == "wata":
|
||||
# Оплата через WATA
|
||||
if not settings.is_wata_enabled():
|
||||
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через WATA
|
||||
await callback.answer("❌ Оплата через WATA пока не реализована", show_alert=True)
|
||||
if price_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS or price_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
"❌ Сумма для WATA должна быть между {min_amount} и {max_amount}.".format(
|
||||
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
|
||||
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
try:
|
||||
wata_result = await payment_service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=settings.get_subscription_payment_description(
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания WATA платежа: %s", error)
|
||||
wata_result = None
|
||||
|
||||
if not wata_result or not wata_result.get("payment_url"):
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"WATA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = wata_result["payment_url"]
|
||||
payment_link_id = wata_result.get("payment_link_id")
|
||||
local_payment_id = wata_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_wata_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
message_template = texts.t(
|
||||
"WATA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через WATA</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через WATA'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_template.format(
|
||||
amount=settings.format_price(price_kopeks),
|
||||
payment_id=payment_link_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
@@ -815,6 +1260,311 @@ async def handle_simple_subscription_payment_method(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_simple_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.rsplit('_', 1)[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else {}
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
db_user = getattr(callback, "db_user", None)
|
||||
texts = get_texts(db_user.language if db_user else settings.DEFAULT_LANGUAGE)
|
||||
|
||||
message_lines = [
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines += ["", "✅ Платеж успешно завершен! Средства уже зачислены."]
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines += [
|
||||
"",
|
||||
"⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.",
|
||||
]
|
||||
if sbp_link:
|
||||
message_lines += ["", f"🏦 СБП: {sbp_link}"]
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines += [
|
||||
"",
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error).lower():
|
||||
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych для простой подписки: {error}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_simple_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.rsplit('_', 1)[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
user_language = settings.DEFAULT_LANGUAGE
|
||||
try:
|
||||
from app.services.payment_service import get_user_by_id as fetch_user_by_id
|
||||
|
||||
user = await fetch_user_by_id(db, payment.user_id)
|
||||
if user and getattr(user, "language", None):
|
||||
user_language = user.language
|
||||
except Exception as error:
|
||||
logger.debug("Не удалось получить пользователя для MulenPay статуса: %s", error)
|
||||
|
||||
texts = get_texts(user_language)
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:",
|
||||
"",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_simple_cryptobot_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.rsplit('_', 1)[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
|
||||
|
||||
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_labels = {
|
||||
"active": ("⏳", "Ожидает оплаты"),
|
||||
"paid": ("✅", "Оплачен"),
|
||||
"expired": ("❌", "Истек"),
|
||||
}
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
language = settings.DEFAULT_LANGUAGE
|
||||
try:
|
||||
from app.services.payment_service import get_user_by_id as fetch_user_by_id
|
||||
|
||||
user = await fetch_user_by_id(db, payment.user_id)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
except Exception as error:
|
||||
logger.debug("Не удалось получить пользователя для CryptoBot статуса: %s", error)
|
||||
|
||||
texts = get_texts(language)
|
||||
message_lines = [
|
||||
"🪙 <b>Статус платежа CryptoBot</b>",
|
||||
"",
|
||||
f"🆔 ID: {payment.invoice_id}",
|
||||
f"💰 Сумма: {payment.amount} {payment.asset}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}",
|
||||
]
|
||||
|
||||
if payment.status == "paid":
|
||||
message_lines.append("\n✅ Платеж подтвержден. Средства уже зачислены.")
|
||||
elif payment.status == "active":
|
||||
message_lines.append("\n⏳ Платеж еще ожидает подтверждения. Оплатите счет и проверьте статус позже.")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_cryptobot_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_simple_wata_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.rsplit('_', 1)[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
|
||||
status_labels = {
|
||||
"Opened": ("⏳", texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")),
|
||||
"Closed": ("⌛", texts.t("WATA_STATUS_CLOSED", "Обрабатывается")),
|
||||
"Paid": ("✅", texts.t("WATA_STATUS_PAID", "Оплачен")),
|
||||
"Declined": ("❌", texts.t("WATA_STATUS_DECLINED", "Отклонен")),
|
||||
}
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")))
|
||||
|
||||
message_lines = [
|
||||
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
|
||||
"",
|
||||
f"🆔 ID: {payment.payment_link_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.")
|
||||
elif payment.status in {"Opened", "Closed"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_simple_wata_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
def register_simple_subscription_handlers(dp):
|
||||
"""Регистрирует обработчики простой покупки подписки."""
|
||||
|
||||
@@ -842,3 +1592,28 @@ def register_simple_subscription_handlers(dp):
|
||||
handle_simple_subscription_payment_method,
|
||||
F.data.startswith("simple_subscription_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_simple_pal24_payment_status,
|
||||
F.data.startswith("check_simple_pal24_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_simple_mulenpay_payment_status,
|
||||
F.data.startswith("check_simple_mulenpay_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_simple_cryptobot_payment_status,
|
||||
F.data.startswith("check_simple_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_simple_wata_payment_status,
|
||||
F.data.startswith("check_simple_wata_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_simple_pal24_payment_status,
|
||||
F.data.startswith("check_simple_pal24_")
|
||||
)
|
||||
|
||||
@@ -527,18 +527,7 @@ async def start_subscription_purchase(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Если включена простая покупка, показываем дополнительную кнопку
|
||||
keyboard = get_subscription_period_keyboard(db_user.language)
|
||||
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
# Добавляем кнопку простой подписки в начало клавиатуры
|
||||
simple_subscription_button = [types.InlineKeyboardButton(
|
||||
text="⚡ Простая покупка",
|
||||
callback_data="simple_subscription_purchase"
|
||||
)]
|
||||
|
||||
# Вставляем кнопку в начало списка кнопок
|
||||
keyboard.inline_keyboard.insert(0, simple_subscription_button)
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
|
||||
+38
-40
@@ -257,7 +257,8 @@ def get_main_menu_keyboard(
|
||||
"💰 Баланс: {balance}",
|
||||
).format(balance=texts.format_price(balance_kopeks))
|
||||
|
||||
keyboard = []
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
paired_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -312,22 +313,24 @@ def get_main_menu_keyboard(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
)
|
||||
|
||||
subscription_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
@@ -337,48 +340,38 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
|
||||
)
|
||||
if simple_purchase_button:
|
||||
subscription_buttons.append(simple_purchase_button)
|
||||
simple_purchase_button = None
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
elif len(subscription_buttons) == 1:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
elif len(subscription_buttons) > 2:
|
||||
# Если больше 2 кнопок, добавляем по отдельности
|
||||
for button in subscription_buttons:
|
||||
keyboard.append([button])
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
if simple_purchase_button:
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
paired_buttons.append(button)
|
||||
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
additional_buttons = [
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
]
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
additional_buttons.append(
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
)
|
||||
|
||||
keyboard.append(additional_buttons)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
@@ -387,21 +380,26 @@ def get_main_menu_keyboard(
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
if support_enabled:
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(paired_buttons), 2):
|
||||
row = paired_buttons[i : i + 2]
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
|
||||
@@ -1347,5 +1347,8 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes"
|
||||
"YES": "✅ Yes",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ Your trial subscription is active for another {days} days. Purchasing a new plan will extend the validity.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ Your trial is still running (expires in {days} days). Buying a paid plan will extend it automatically.",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
|
||||
}
|
||||
|
||||
@@ -745,7 +745,7 @@
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Требуется пополнение баланса</b>\\n\\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\\n\\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\\n\\nВыберите способ пополнения:",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
@@ -1347,5 +1347,8 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да"
|
||||
"YES": "✅ Да",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ У вас уже есть активная триальная подписка. Она будет действовать ещё {days} дн. После покупки к сроку добавится оплаченный период.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ У вас сейчас триальная подписка. Она истекает через {days} дн. Покупка новой продлит срок автоматически.",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
|
||||
@@ -81,6 +81,7 @@ class BotConfigurationService:
|
||||
"WATA": "💠 Wata",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
@@ -131,6 +132,7 @@ class BotConfigurationService:
|
||||
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"SIMPLE_SUBSCRIPTION": "Параметры упрощённой покупки: период, трафик, устройства и сквады.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
@@ -230,6 +232,11 @@ class BotConfigurationService:
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
|
||||
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
@@ -284,6 +291,7 @@ class BotConfigurationService:
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"WATA_": "WATA",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
@@ -394,12 +402,47 @@ class BotConfigurationService:
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
|
||||
"description": (
|
||||
"Включает оплату через YooKassa. "
|
||||
"Требует корректных идентификаторов магазина и секретного ключа."
|
||||
),
|
||||
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
|
||||
"example": "Включено при полностью настроенной интеграции.",
|
||||
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
|
||||
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": {
|
||||
"description": "Показывает в меню пункт с быстрой покупкой подписки.",
|
||||
"format": "Булево значение.",
|
||||
"example": "true",
|
||||
"warning": "Если остались не настроенные параметры, предложение может вести себя некорректно.",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": {
|
||||
"description": "Период подписки, который предлагается при быстрой покупке.",
|
||||
"format": "Выберите один из доступных периодов.",
|
||||
"example": "30 дн. — 990 ₽",
|
||||
"warning": "Не забудьте настроить цену периода в блоке «Стоимость тарифов».",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": {
|
||||
"description": "Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.",
|
||||
"format": "Выберите число устройств.",
|
||||
"example": "2 устройства",
|
||||
"warning": "Значение не должно превышать допустимый лимит в настройках подписок.",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": {
|
||||
"description": "Объём трафика, включённый в простую подписку (0 = безлимит).",
|
||||
"format": "Выберите пакет трафика.",
|
||||
"example": "Безлимит",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": {
|
||||
"description": (
|
||||
"Привязка быстрой подписки к конкретному скваду. "
|
||||
"Оставьте пустым для любого доступного сервера."
|
||||
),
|
||||
"format": "Выберите сквад из списка или очистите значение.",
|
||||
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
|
||||
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
@@ -422,7 +465,10 @@ class BotConfigurationService:
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
|
||||
"description": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
"пользователь будет заблокирован."
|
||||
),
|
||||
"format": "Перечислите ключевые слова через запятую или с новой строки.",
|
||||
"example": "support, security, служебн",
|
||||
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
|
||||
@@ -443,10 +489,16 @@ class BotConfigurationService:
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_TIMES",
|
||||
},
|
||||
"REMNAWAVE_AUTO_SYNC_TIMES": {
|
||||
"description": "Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.",
|
||||
"description": (
|
||||
"Список времени в формате HH:MM, когда запускается автосинхронизация "
|
||||
"в течение суток."
|
||||
),
|
||||
"format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).",
|
||||
"example": "03:00, 15:00",
|
||||
"warning": "Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.",
|
||||
"warning": (
|
||||
"Минимальный интервал между запусками не ограничен, но слишком частые "
|
||||
"синхронизации нагружают панель."
|
||||
),
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
@@ -515,6 +567,14 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def format_value_human(cls, key: str, value: Any) -> str:
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
if value is None:
|
||||
return "Любой доступный"
|
||||
if isinstance(value, str):
|
||||
cleaned_value = value.strip()
|
||||
if not cleaned_value:
|
||||
return "Любой доступный"
|
||||
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -743,8 +803,179 @@ class BotConfigurationService:
|
||||
@classmethod
|
||||
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
|
||||
cls.initialize_definitions()
|
||||
dynamic = cls._get_dynamic_choice_options(key)
|
||||
if dynamic is not None:
|
||||
cls.CHOICES[key] = dynamic
|
||||
cls._invalidate_choice_cache(key)
|
||||
return dynamic
|
||||
return cls.CHOICES.get(key, [])
|
||||
|
||||
@classmethod
|
||||
def _invalidate_choice_cache(cls, key: str) -> None:
|
||||
cls._choice_tokens.pop(key, None)
|
||||
cls._choice_token_lookup.pop(key, None)
|
||||
|
||||
@classmethod
|
||||
def _get_dynamic_choice_options(cls, key: str) -> Optional[List[ChoiceOption]]:
|
||||
if key == "SIMPLE_SUBSCRIPTION_PERIOD_DAYS":
|
||||
return cls._build_simple_subscription_period_choices()
|
||||
if key == "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT":
|
||||
return cls._build_simple_subscription_device_choices()
|
||||
if key == "SIMPLE_SUBSCRIPTION_TRAFFIC_GB":
|
||||
return cls._build_simple_subscription_traffic_choices()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_simple_subscription_period_choices() -> List[ChoiceOption]:
|
||||
raw_periods = str(getattr(settings, "AVAILABLE_SUBSCRIPTION_PERIODS", "") or "")
|
||||
period_values: set[int] = set()
|
||||
|
||||
for segment in raw_periods.split(","):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
try:
|
||||
period = int(segment)
|
||||
except ValueError:
|
||||
continue
|
||||
if period > 0:
|
||||
period_values.add(period)
|
||||
|
||||
fallback_period = getattr(settings, "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", 30) or 30
|
||||
try:
|
||||
fallback_period = int(fallback_period)
|
||||
except (TypeError, ValueError):
|
||||
fallback_period = 30
|
||||
period_values.add(max(1, fallback_period))
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for days in sorted(period_values):
|
||||
price_attr = f"PRICE_{days}_DAYS"
|
||||
price_value = getattr(settings, price_attr, None)
|
||||
if not isinstance(price_value, int):
|
||||
price_value = settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
label = f"{days} дн."
|
||||
try:
|
||||
if isinstance(price_value, int):
|
||||
label = f"{label} — {settings.format_price(price_value)}"
|
||||
except Exception:
|
||||
logger.debug("Не удалось форматировать цену для периода %s", days, exc_info=True)
|
||||
|
||||
options.append(ChoiceOption(days, label))
|
||||
|
||||
return options
|
||||
|
||||
@classmethod
|
||||
def _build_simple_subscription_device_choices(cls) -> List[ChoiceOption]:
|
||||
default_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) or 1
|
||||
try:
|
||||
default_limit = int(default_limit)
|
||||
except (TypeError, ValueError):
|
||||
default_limit = 1
|
||||
|
||||
max_limit = getattr(settings, "MAX_DEVICES_LIMIT", default_limit) or default_limit
|
||||
try:
|
||||
max_limit = int(max_limit)
|
||||
except (TypeError, ValueError):
|
||||
max_limit = default_limit
|
||||
|
||||
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", default_limit) or default_limit
|
||||
try:
|
||||
current_limit = int(current_limit)
|
||||
except (TypeError, ValueError):
|
||||
current_limit = default_limit
|
||||
|
||||
upper_bound = max(default_limit, max_limit, current_limit, 1)
|
||||
upper_bound = min(max(upper_bound, 1), 50)
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for count in range(1, upper_bound + 1):
|
||||
label = f"{count} {cls._pluralize_devices(count)}"
|
||||
if count == default_limit:
|
||||
label = f"{label} (по умолчанию)"
|
||||
options.append(ChoiceOption(count, label))
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def _build_simple_subscription_traffic_choices() -> List[ChoiceOption]:
|
||||
try:
|
||||
packages = settings.get_traffic_packages()
|
||||
except Exception as error:
|
||||
logger.warning("Не удалось получить пакеты трафика: %s", error, exc_info=True)
|
||||
packages = []
|
||||
|
||||
traffic_values: set[int] = {0}
|
||||
for package in packages:
|
||||
gb_value = package.get("gb")
|
||||
try:
|
||||
gb = int(gb_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if gb >= 0:
|
||||
traffic_values.add(gb)
|
||||
|
||||
default_limit = getattr(settings, "DEFAULT_TRAFFIC_LIMIT_GB", 0) or 0
|
||||
try:
|
||||
default_limit = int(default_limit)
|
||||
except (TypeError, ValueError):
|
||||
default_limit = 0
|
||||
if default_limit >= 0:
|
||||
traffic_values.add(default_limit)
|
||||
|
||||
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_TRAFFIC_GB", default_limit)
|
||||
try:
|
||||
current_limit = int(current_limit)
|
||||
except (TypeError, ValueError):
|
||||
current_limit = default_limit
|
||||
if current_limit >= 0:
|
||||
traffic_values.add(current_limit)
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for gb in sorted(traffic_values):
|
||||
if gb <= 0:
|
||||
label = "Безлимит"
|
||||
else:
|
||||
label = f"{gb} ГБ"
|
||||
|
||||
price_label = None
|
||||
for package in packages:
|
||||
try:
|
||||
package_gb = int(package.get("gb"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if package_gb != gb:
|
||||
continue
|
||||
price_raw = package.get("price")
|
||||
try:
|
||||
price_value = int(price_raw)
|
||||
if price_value >= 0:
|
||||
price_label = settings.format_price(price_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
break
|
||||
|
||||
if price_label:
|
||||
label = f"{label} — {price_label}"
|
||||
|
||||
options.append(ChoiceOption(gb, label))
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def _pluralize_devices(count: int) -> str:
|
||||
count = abs(int(count))
|
||||
last_two = count % 100
|
||||
last_one = count % 10
|
||||
if 11 <= last_two <= 14:
|
||||
return "устройств"
|
||||
if last_one == 1:
|
||||
return "устройство"
|
||||
if 2 <= last_one <= 4:
|
||||
return "устройства"
|
||||
return "устройств"
|
||||
|
||||
@classmethod
|
||||
def has_choices(cls, key: str) -> bool:
|
||||
return bool(cls.get_choice_options(key))
|
||||
@@ -1055,4 +1286,3 @@ class BotConfigurationService:
|
||||
|
||||
|
||||
bot_configuration_service = BotConfigurationService
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update, func
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
@@ -26,6 +26,7 @@ from app.database.models import (
|
||||
TransactionType
|
||||
)
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user