Merge pull request #1414 from Fr1ngg/main

w
This commit is contained in:
Egor
2025-10-21 07:42:49 +03:00
committed by GitHub
12 changed files with 1335 additions and 96 deletions
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
+4 -6
View File
@@ -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:
+245 -3
View File
@@ -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,
)
+794 -19
View 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_")
)
-11
View File
@@ -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
View File
@@ -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}")
+4 -1
View File
@@ -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"
}
+5 -2
View File
@@ -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": "⚡ Простая покупка"
}
+236 -6
View File
@@ -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
+2 -1
View File
@@ -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__)