This commit is contained in:
gy9vin
2025-12-30 21:50:42 +03:00
18 changed files with 186 additions and 61 deletions

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.9.3-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-$(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.9.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-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.9.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi

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.9.3-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT

View File

@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.3"
ARG VERSION="v2.9.4"
ARG BUILD_DATE
ARG VCS_REF

View File

@@ -158,6 +158,10 @@ class Settings(BaseSettings):
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
# Режим выбора трафика:
# - selectable: пользователь выбирает трафик при покупке и может докупать
# - fixed: фиксированный лимит, без выбора и без докупки
# - fixed_with_topup: фиксированный лимит при покупке, но докупка разрешена (при продлении сброс до лимита)
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
@@ -1058,6 +1062,11 @@ class Settings(BaseSettings):
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
def is_traffic_fixed(self) -> bool:
"""Возвращает True если выбор трафика отключён (fixed или fixed_with_topup)"""
return self.TRAFFIC_SELECTION_MODE.lower() in ("fixed", "fixed_with_topup")
def is_traffic_topup_blocked(self) -> bool:
"""Возвращает True если докупка трафика полностью заблокирована (только fixed)"""
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:

View File

@@ -344,6 +344,15 @@ async def extend_subscription(
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
if settings.is_traffic_fixed() and days > 0:
fixed_limit = settings.get_fixed_traffic_limit()
old_limit = subscription.traffic_limit_gb
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ")
subscription.updated_at = current_time
await db.commit()
@@ -1158,7 +1167,12 @@ async def get_subscription_renewal_cost(
total_servers_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,

View File

@@ -198,6 +198,7 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
choices=(
ChoiceOption("selectable", "Выбор пакетов", "Selectable"),
ChoiceOption("fixed", "Фиксированный лимит", "Fixed limit"),
ChoiceOption("fixed_with_topup", "Фикс. лимит + докупка", "Fixed + topup"),
),
description_ru="Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.",
description_en="Defines whether users pick packages or use a fixed limit.",
@@ -351,8 +352,11 @@ def _format_core_summary(lang_code: str) -> str:
base_price = settings.format_price(settings.BASE_SUBSCRIPTION_PRICE)
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
if settings.TRAFFIC_SELECTION_MODE == "fixed":
mode = settings.TRAFFIC_SELECTION_MODE.lower()
if mode == "fixed":
traffic_mode = "⚙️ fixed"
elif mode == "fixed_with_topup":
traffic_mode = "⚙️ fixed+topup"
else:
traffic_mode = "⚙️ selectable"
traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True)

View File

@@ -1362,7 +1362,12 @@ async def handle_extend_subscription(
devices_price_info = calculate_user_price(db_user, devices_total_base, days, "devices")
# 4. Calculate traffic price with promo group discount
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_total_base = traffic_price_per_month * months_in_period
traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, "traffic")
@@ -1579,7 +1584,12 @@ async def confirm_extend_subscription(
discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_price_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
days,
@@ -1731,6 +1741,17 @@ async def confirm_extend_subscription(
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
traffic_was_reset = False
old_traffic_limit = subscription.traffic_limit_gb
if settings.is_traffic_fixed():
fixed_limit = settings.get_fixed_traffic_limit()
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
traffic_was_reset = True
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ")
await db.commit()
await db.refresh(subscription)
await db.refresh(db_user)
@@ -1803,6 +1824,11 @@ async def confirm_extend_subscription(
f"💰 Списано: {texts.format_price(price)}"
)
# Добавляем уведомление о сбросе трафика
if traffic_was_reset:
fixed_limit = settings.get_fixed_traffic_limit()
success_message += f"\n\n📊 Трафик сброшен до {fixed_limit} ГБ"
if promo_component["discount"] > 0:
success_message += (
f" (включая доп. скидку {promo_component['percent']}%:"

View File

@@ -107,7 +107,7 @@ async def handle_add_traffic(
)
return
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
@@ -206,7 +206,7 @@ async def handle_reset_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
@@ -266,7 +266,7 @@ async def confirm_reset_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
@@ -461,7 +461,7 @@ async def add_traffic(
db_user: User,
db: AsyncSession
):
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
@@ -609,7 +609,7 @@ async def handle_switch_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return

View File

@@ -511,6 +511,7 @@ def get_main_menu_keyboard(
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
and settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
@@ -992,7 +993,20 @@ def get_subscription_keyboard(
callback_data="subscription_settings",
)
])
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and (subscription.traffic_limit_gb or 0) > 0
):
keyboard.append([
InlineKeyboardButton(
text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"),
callback_data="buy_traffic"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
@@ -1134,10 +1148,10 @@ def get_subscription_period_keyboard(
def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
import logging
logger = logging.getLogger(__name__)
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
return get_back_keyboard(language)
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
@@ -1863,8 +1877,8 @@ def get_reset_traffic_confirm_keyboard(
missing_kopeks: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
return get_back_keyboard(language)
texts = get_texts(language)

View File

@@ -7,9 +7,7 @@ import hashlib
import hmac
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import httpx
@@ -35,12 +33,10 @@ class CloudPaymentsService:
public_id: Optional[str] = None,
api_secret: Optional[str] = None,
api_url: Optional[str] = None,
widget_url: Optional[str] = None,
) -> None:
self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID
self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET
self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/")
self.widget_url = (widget_url or settings.CLOUDPAYMENTS_WIDGET_URL).rstrip("/")
@property
def is_configured(self) -> bool:
@@ -114,16 +110,18 @@ class CloudPaymentsService:
"""Convert rubles to kopeks."""
return int(amount * 100)
def generate_payment_link(
async def generate_payment_link(
self,
telegram_id: int,
amount_kopeks: int,
invoice_id: str,
description: Optional[str] = None,
email: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> str:
"""
Generate a payment widget URL for CloudPayments.
Create a payment order via CloudPayments API and return payment URL.
Args:
telegram_id: User's Telegram ID (will be used as AccountId)
@@ -131,35 +129,65 @@ class CloudPaymentsService:
invoice_id: Unique invoice ID for this payment
description: Payment description
email: User's email (optional)
success_redirect_url: Redirect URL after successful payment
fail_redirect_url: Redirect URL after failed payment
Returns:
URL to CloudPayments payment widget
URL to CloudPayments payment page
"""
if not self.public_id:
raise CloudPaymentsAPIError("CloudPayments public_id not configured")
if not self.is_configured:
raise CloudPaymentsAPIError("CloudPayments is not configured")
amount = self._amount_from_kopeks(amount_kopeks)
params = {
"publicId": self.public_id,
"description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
"amount": amount,
"currency": settings.CLOUDPAYMENTS_CURRENCY,
"accountId": str(telegram_id),
"invoiceId": invoice_id,
"skin": settings.CLOUDPAYMENTS_SKIN,
# Формируем данные для создания заказа через API /orders/create
payload: Dict[str, Any] = {
"Amount": amount,
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
"AccountId": str(telegram_id),
"InvoiceId": invoice_id,
"JsonData": {
"telegram_id": telegram_id,
"invoice_id": invoice_id,
},
}
if settings.CLOUDPAYMENTS_REQUIRE_EMAIL:
params["requireEmail"] = "true"
if email:
params["email"] = email
payload["Email"] = email
# Добавляем JSON данные для webhook
params["data"] = f'{{"telegram_id": {telegram_id}, "invoice_id": "{invoice_id}"}}'
if settings.CLOUDPAYMENTS_REQUIRE_EMAIL:
payload["RequireConfirmation"] = False
return f"{self.widget_url}?{urlencode(params)}"
# URL для редиректа после оплаты
if success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL:
payload["SuccessRedirectUrl"] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL
if fail_redirect_url:
payload["FailRedirectUrl"] = fail_redirect_url
# Создаём заказ через API
response = await self._request("POST", "/orders/create", json=payload)
if not response.get("Success"):
error_message = response.get("Message", "Unknown error")
logger.error("CloudPayments orders/create failed: %s", error_message)
raise CloudPaymentsAPIError(f"Failed to create order: {error_message}")
model = response.get("Model", {})
payment_url = model.get("Url")
if not payment_url:
logger.error("CloudPayments orders/create returned no URL: %s", response)
raise CloudPaymentsAPIError("CloudPayments API returned no payment URL")
logger.info(
"CloudPayments order created: id=%s, url=%s",
model.get("Id"),
payment_url,
)
return payment_url
def generate_invoice_id(self, telegram_id: int) -> str:
"""Generate unique invoice ID for a payment."""

View File

@@ -75,8 +75,8 @@ class CloudPaymentsPaymentMixin:
invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id)
try:
# Generate payment widget URL
payment_url = self.cloudpayments_service.generate_payment_link(
# Create payment order via CloudPayments API
payment_url = await self.cloudpayments_service.generate_payment_link(
telegram_id=telegram_id,
amount_kopeks=amount_kopeks,
invoice_id=invoice_id,

View File

@@ -680,11 +680,19 @@ async def auto_activate_subscription_after_topup(
# Определяем параметры подписки
if subscription:
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb or 0
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
traffic_limit_gb = subscription.traffic_limit_gb or 0
connected_squads = subscription.connected_squads or []
else:
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = 0
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
traffic_limit_gb = 0
connected_squads = []
# Если серверы не выбраны — берём бесплатные по умолчанию

View File

@@ -511,9 +511,11 @@ class MiniAppSubscriptionPurchaseService:
) -> PurchaseTrafficConfig:
if settings.is_traffic_fixed():
value = fixed_traffic_value if fixed_traffic_value is not None else settings.get_fixed_traffic_limit()
# Передаём актуальный режим (fixed или fixed_with_topup)
actual_mode = settings.TRAFFIC_SELECTION_MODE.lower()
return PurchaseTrafficConfig(
selectable=False,
mode="fixed",
mode=actual_mode,
options=[],
default_value=value,
current_value=value,

View File

@@ -319,9 +319,13 @@ class SubscriptionRenewalService:
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
else:
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit
if devices_limit is None:

View File

@@ -730,7 +730,12 @@ class SubscriptionService:
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
@@ -1072,7 +1077,12 @@ class SubscriptionService:
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,

View File

@@ -355,6 +355,7 @@ class BotConfigurationService:
"TRAFFIC_SELECTION_MODE": [
ChoiceOption("selectable", "📦 Выбор пакетов"),
ChoiceOption("fixed", "📏 Фиксированный лимит"),
ChoiceOption("fixed_with_topup", "📏 Фикс. лимит + докупка"),
],
"DEFAULT_TRAFFIC_RESET_STRATEGY": [
ChoiceOption("NO_RESET", "♾️ Без сброса"),

View File

@@ -4690,7 +4690,8 @@ async def _build_subscription_settings(
)
traffic_options: List[MiniAppSubscriptionTrafficOption] = []
if settings.is_traffic_selectable():
# В режиме fixed_with_topup показываем опции трафика (для докупки)
if not settings.is_traffic_topup_blocked():
for package in settings.get_traffic_packages():
is_enabled = bool(package.get("enabled", True))
if package.get("is_active") is False:
@@ -4764,7 +4765,7 @@ async def _build_subscription_settings(
),
traffic=MiniAppSubscriptionTrafficSettings(
options=traffic_options,
can_update=settings.is_traffic_selectable(),
can_update=not settings.is_traffic_topup_blocked(),
current_value=subscription.traffic_limit_gb,
),
devices=MiniAppSubscriptionDevicesSettings(
@@ -5512,7 +5513,9 @@ async def update_subscription_traffic_endpoint(
if new_traffic == subscription.traffic_limit_gb:
return MiniAppSubscriptionUpdateResponse(success=True, message="No changes")
if not settings.is_traffic_selectable():
# В режиме fixed полностью блокируем изменение трафика
# В режиме fixed_with_topup разрешаем докупку (is_traffic_topup_blocked = False)
if settings.is_traffic_topup_blocked():
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={

View File

@@ -16166,7 +16166,8 @@
}
function ensurePurchaseTrafficSelection(config) {
const selectable = config && config.selectable !== false && String(config.mode || '').toLowerCase() !== 'fixed';
const mode = String(config?.mode || '').toLowerCase();
const selectable = config && config.selectable !== false && !['fixed', 'fixed_with_topup'].includes(mode);
const options = ensureArray(config?.options || config?.available || []);
if (!selectable || !options.length) {
if (config && (config.current !== undefined || config.default !== undefined)) {
@@ -16937,7 +16938,8 @@
}
const config = getSubscriptionPurchaseTrafficConfig(period);
const selectable = config && config.selectable !== false && String(config.mode || '').toLowerCase() !== 'fixed';
const mode = String(config?.mode || '').toLowerCase();
const selectable = config && config.selectable !== false && !['fixed', 'fixed_with_topup'].includes(mode);
const options = ensureArray(config?.options || config?.available || []);
optionsContainer.innerHTML = '';