Compare commits

...

11 Commits

Author SHA1 Message Date
Egor fd2e419e8e Merge pull request #2648 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.20.0
2026-02-25 12:49:20 +03:00
github-actions[bot] aaf0263fda chore(main): release 3.20.0 2026-02-25 09:48:22 +00:00
Egor d4d5031cc2 Merge pull request #2647 from BEDOLAGA-DEV/dev
Dev
2026-02-25 12:47:57 +03:00
Fringg b2d7abf5bd fix: resolve ruff lint errors (import sorting, unused variable) 2026-02-25 12:42:26 +03:00
Fringg 0f9f843236 style: format branding routes 2026-02-25 12:40:49 +03:00
Fringg cab425cfac style: format freekassa handler and keyboard files 2026-02-25 12:39:21 +03:00
Fringg 0da0c5547d feat: add separate Freekassa SBP and card payment methods
Split Freekassa into sub-methods: СБП/QR (i=44) and Карты РФ (i=36).
Each method has independent enable/display_name settings, dedicated
handlers, keyboard buttons, and correct payment_system_id routing.
Webhook notifications resolve display name from payment metadata.
2026-02-25 12:32:05 +03:00
Fringg 988d0e5c2f fix: initialize logger in bot_configuration.py
Add missing structlog import and logger initialization.
Without this, any code path hitting logger.info/warning/error
would raise NameError at runtime.
2026-02-25 11:55:07 +03:00
Fringg 1ce91749aa fix: resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave
1. Remove pointless HWID reset during auto-sync deactivation — user
   doesn't exist in panel, API returns 404, UUID is cleaned up below.

2. Clean up RESTRICT FK references (AdminAuditLog, WithdrawalRequest,
   AdminRole, UserRole, AccessPolicy) before deleting user to prevent
   IntegrityError on admin_audit_log_user_id_fkey.

3. Fix device limit not being sent to RemnaWave when
   DEVICES_SELECTION_DISABLED_AMOUNT=0: treat 0 as "no forced override"
   instead of sending hwidDeviceLimit:0 (which Remnawave interprets as
   unlimited). Now falls through to subscription.device_limit from tariff.

4. Add info-level logging to POST /api/users (was debug) to match
   existing PATCH logging for device limit diagnostics.
2026-02-25 11:53:49 +03:00
Fringg 731eb24364 fix: remove gemini-effect and noise from allowed background types 2026-02-25 07:43:46 +03:00
Fringg a15403b8b6 feat: add validation to animation config API
- Add Literal type whitelist for background type field
- Add settings dict validation (max 20 keys, no nested objects, bounded values)
- Add opacity (0-1) and blur (0-100) bounds with Pydantic Field constraints
- Fix mutable default dict with Field(default_factory=dict)
2026-02-25 07:13:07 +03:00
17 changed files with 472 additions and 44 deletions
+7
View File
@@ -630,6 +630,13 @@ FREEKASSA_WEBHOOK_PORT=8088
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# Раздельные методы оплаты (отображаются как отдельные кнопки)
# СБП (QR код) — i=44
FREEKASSA_SBP_ENABLED=false
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
# Карты РФ — i=36
FREEKASSA_CARD_ENABLED=false
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.19.0"
".": "3.20.0"
}
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
### New Features
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
### Bug Fixes
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.19.0" # x-release-please-version
ARG VERSION="v3.20.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+162 -1
View File
@@ -3,11 +3,12 @@
import json
import os
from pathlib import Path
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,17 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
# Default animation config
DEFAULT_ANIMATION_CONFIG = {
'enabled': True,
'type': 'aurora',
'settings': {},
'opacity': 1.0,
'blur': 0,
'reducedOnMobile': True,
}
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
@@ -121,6 +133,92 @@ class AnimationEnabledUpdate(BaseModel):
enabled: bool
ALLOWED_BG_TYPES = (
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
)
MAX_SETTINGS_KEYS = 20
MAX_SETTINGS_VALUE_LEN = 200
def _validate_settings(v: dict) -> dict:
"""Validate settings dict: flat structure, bounded size, no nested objects."""
if len(v) > MAX_SETTINGS_KEYS:
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
for key, val in v.items():
if not isinstance(key, str) or len(key) > 50:
raise ValueError('Setting keys must be strings under 50 characters')
if isinstance(val, dict | list):
raise ValueError('Nested objects/arrays not allowed in settings')
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
return v
class AnimationConfigResponse(BaseModel):
"""Full animation config."""
enabled: bool = True
type: str = 'aurora'
settings: dict = Field(default_factory=dict)
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
blur: float = Field(default=0, ge=0, le=100)
reducedOnMobile: bool = True
class AnimationConfigUpdate(BaseModel):
"""Request to update animation config (partial update)."""
enabled: bool | None = None
type: (
Literal[
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
]
| None
) = None
settings: dict | None = None
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
blur: float | None = Field(default=None, ge=0, le=100)
reducedOnMobile: bool | None = None
@field_validator('settings')
@classmethod
def validate_settings(cls, v: dict | None) -> dict | None:
if v is None:
return v
return _validate_settings(v)
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
@@ -598,6 +696,69 @@ async def update_animation_enabled(
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Animation Config Routes (new JSON-based) ============
@router.get('/animation-config', response_model=AnimationConfigResponse)
async def get_animation_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get full animation config. Public endpoint."""
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value is not None:
try:
config = json.loads(config_value)
return AnimationConfigResponse(**config)
except (json.JSONDecodeError, TypeError):
pass
# Auto-migrate from old ANIMATION_ENABLED_KEY
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if old_value is not None:
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
return AnimationConfigResponse(**config)
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
@router.patch('/animation-config', response_model=AnimationConfigResponse)
async def update_animation_config(
payload: AnimationConfigUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation config (partial update). Admin only."""
# Get current config
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value:
try:
current = json.loads(config_value)
except (json.JSONDecodeError, TypeError):
current = dict(DEFAULT_ANIMATION_CONFIG)
else:
current = dict(DEFAULT_ANIMATION_CONFIG)
# Merge only provided fields
update_data = payload.model_dump(exclude_none=True)
current.update(update_data)
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
# Also sync old key for backwards compat
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
logger.info(
'Admin updated animation config',
telegram_id=admin.telegram_id,
type=current.get('type'),
enabled=current.get('enabled'),
)
return AnimationConfigResponse(**current)
# ============ Fullscreen Routes ============
+27 -2
View File
@@ -500,6 +500,11 @@ class Settings(BaseSettings):
FREEKASSA_USE_API: bool = False
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: str | None = None
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
# KassaAI (api.fk.life) - отдельная платёжка
KASSA_AI_ENABLED: bool = False
@@ -1525,8 +1530,8 @@ class Settings(BaseSettings):
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
return None
if value < 0:
return 0
if value <= 0:
return None
return value
@@ -1750,6 +1755,26 @@ class Settings(BaseSettings):
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_freekassa_sbp_enabled(self) -> bool:
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name())
def is_freekassa_card_enabled(self) -> bool:
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name())
def is_kassa_ai_enabled(self) -> bool:
return (
self.KASSA_AI_ENABLED
+11 -1
View File
@@ -461,9 +461,19 @@ class RemnaWaveAPI:
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
logger.debug('Создание пользователя в панели', data=data)
logger.info(
'POST /api/users payload',
username=data.get('username'),
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('POST', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'POST /api/users response',
uuid=user.uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None:
+3
View File
@@ -5,6 +5,7 @@ import time
from collections.abc import Iterable
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
from aiogram.filters import BaseFilter, StateFilter
from aiogram.fsm.context import FSMContext
@@ -32,6 +33,8 @@ from app.utils.currency_converter import currency_converter
from app.utils.decorators import admin_required, error_handler
logger = structlog.get_logger(__name__)
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
+139 -11
View File
@@ -1,5 +1,7 @@
"""Handler for Freekassa balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -18,12 +20,29 @@ from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
FREEKASSA_SUB_METHODS = {
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
}
def _resolve_freekassa_params(
payment_method: str | None,
) -> tuple[int | None, str]:
"""Return (payment_system_id, display_name) for a freekassa sub-method key."""
if payment_method and payment_method in FREEKASSA_SUB_METHODS:
meta = FREEKASSA_SUB_METHODS[payment_method]
return meta['payment_system_id'], meta['get_name']()
return None, settings.get_freekassa_display_name()
async def _create_freekassa_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method: str | None = None,
):
"""
Common logic for creating Freekassa payment and sending response.
@@ -34,10 +53,13 @@ async def _create_freekassa_payment_and_respond(
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
payment_method: Sub-method key (freekassa_sbp, freekassa_card, or None for default)
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
ps_id, display_name = _resolve_freekassa_params(payment_method)
# Create payment
payment_service = PaymentService()
@@ -53,6 +75,8 @@ async def _create_freekassa_payment_and_respond(
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_system_id=ps_id,
payment_method=payment_method,
)
if not result:
@@ -74,7 +98,6 @@ async def _create_freekassa_payment_and_respond(
return
payment_url = result.get('payment_url')
display_name = settings.get_freekassa_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
@@ -103,7 +126,7 @@ async def _create_freekassa_payment_and_respond(
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
).format(name=html.escape(display_name), amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
@@ -128,9 +151,11 @@ async def process_freekassa_payment_amount(
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
payment_method: str | None = None,
):
"""
Process payment amount directly (called from quick_amount handlers).
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -183,21 +208,44 @@ async def process_freekassa_payment_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method=payment_method,
)
@error_handler
async def start_freekassa_topup(
async def _start_freekassa_topup_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Start Freekassa top-up process - ask for amount.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
# Проверка доступности метода
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
@@ -215,11 +263,11 @@ async def start_freekassa_topup(
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='freekassa')
await state.update_data(payment_method=payment_method)
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_freekassa_display_name()
_, display_name = _resolve_freekassa_params(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
@@ -249,6 +297,39 @@ async def start_freekassa_topup(
)
@error_handler
async def start_freekassa_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa')
@error_handler
async def start_freekassa_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_sbp')
@error_handler
async def start_freekassa_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_card')
FREEKASSA_PAYMENT_METHODS = {'freekassa', 'freekassa_sbp', 'freekassa_card'}
@error_handler
async def process_freekassa_custom_amount(
message: types.Message,
@@ -260,7 +341,7 @@ async def process_freekassa_custom_amount(
Process custom amount input for Freekassa payment.
"""
data = await state.get_data()
if data.get('payment_method') != 'freekassa':
if data.get('payment_method') not in FREEKASSA_PAYMENT_METHODS:
return
texts = get_texts(db_user.language)
@@ -285,19 +366,21 @@ async def process_freekassa_custom_amount(
db=db,
amount_kopeks=amount_kopeks,
state=state,
payment_method=data.get('payment_method'),
)
@error_handler
async def process_freekassa_quick_amount(
async def _process_freekassa_quick_amount_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -308,7 +391,21 @@ async def process_freekassa_quick_amount(
)
return
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
@@ -363,4 +460,35 @@ async def process_freekassa_quick_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
@error_handler
async def process_freekassa_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
@error_handler
async def process_freekassa_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
+16 -3
View File
@@ -113,11 +113,13 @@ async def route_payment_by_method(
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'freekassa':
if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'):
from .freekassa import process_freekassa_payment_amount
async with AsyncSessionLocal() as db:
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
await process_freekassa_payment_amount(
message, db_user, db, amount_kopeks, state, payment_method=payment_method
)
return True
if payment_method == 'kassa_ai':
@@ -838,10 +840,21 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
from .freekassa import process_freekassa_quick_amount, start_freekassa_topup
from .freekassa import (
process_freekassa_card_quick_amount,
process_freekassa_quick_amount,
process_freekassa_sbp_quick_amount,
start_freekassa_card_topup,
start_freekassa_sbp_topup,
start_freekassa_topup,
)
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
+29 -1
View File
@@ -1569,7 +1569,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_freekassa_enabled():
if settings.is_freekassa_sbp_enabled():
sbp_name = settings.get_freekassa_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_FREEKASSA_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('freekassa_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_freekassa_card_enabled():
card_name = settings.get_freekassa_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_FREEKASSA_CARD', f'💳 {card_name}'),
callback_data=_build_callback('freekassa_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_freekassa_enabled()
and not settings.is_freekassa_sbp_enabled()
and not settings.is_freekassa_card_enabled()
):
freekassa_name = settings.get_freekassa_display_name()
keyboard.append(
[
+22 -4
View File
@@ -32,6 +32,8 @@ class FreekassaPaymentMixin:
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_system_id: int | None = None,
payment_method: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Freekassa.
@@ -83,18 +85,23 @@ class FreekassaPaymentMixin:
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method': payment_method or 'freekassa',
}
try:
# Определяем payment_system_id: явно переданный > из настроек
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
# Выбираем способ создания платежа: API или форма
if settings.FREEKASSA_USE_API:
# Используем API для создания заказа (нужно для NSPK СБП)
# Если указан payment_system_id — всегда используем API
if settings.FREEKASSA_USE_API or ps_id:
# Используем API для создания заказа
payment_url = await freekassa_service.create_order_and_get_url(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID,
payment_system_id=ps_id,
)
logger.info('Freekassa API: создан заказ order_id url', order_id=order_id, payment_url=payment_url)
else:
@@ -339,7 +346,18 @@ class FreekassaPaymentMixin:
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
display_name = settings.get_freekassa_display_name()
# Resolve display name from payment metadata (sub-method aware)
display_name = settings.get_freekassa_display_name_html()
try:
meta = json.loads(payment.metadata_json) if payment.metadata_json else {}
pm = meta.get('payment_method', 'freekassa')
if pm == 'freekassa_sbp':
display_name = settings.get_freekassa_sbp_display_name_html()
elif pm == 'freekassa_card':
display_name = settings.get_freekassa_card_display_name_html()
except (json.JSONDecodeError, AttributeError):
pass
await self.bot.send_message(
user.telegram_id,
(
@@ -98,6 +98,20 @@ def _get_method_defaults() -> dict:
{'id': 'card', 'name': 'Карта'},
],
},
'freekassa_sbp': {
'default_display_name': settings.get_freekassa_sbp_display_name(),
'is_configured': settings.is_freekassa_sbp_enabled(),
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'freekassa_card': {
'default_display_name': settings.get_freekassa_card_display_name(),
'is_configured': settings.is_freekassa_card_enabled(),
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'cloudpayments': {
'default_display_name': settings.get_cloudpayments_display_name(),
'is_configured': settings.is_cloudpayments_enabled(),
@@ -151,6 +165,8 @@ DEFAULT_METHOD_ORDER = [
'platega',
'wata',
'freekassa',
'freekassa_sbp',
'freekassa_card',
'cloudpayments',
'kassa_ai',
]
+3 -16
View File
@@ -1471,14 +1471,12 @@ class RemnaWaveService:
)
# Используем один API клиент для всех операций сброса HWID
hwid_api_client = None
hwid_api_cm = None
try:
hwid_api_cm = self.get_api_client()
hwid_api_client = await hwid_api_cm.__aenter__()
await hwid_api_cm.__aenter__()
except Exception as api_init_error:
logger.warning('⚠️ Не удалось создать API клиент для сброса HWID', api_init_error=api_init_error)
hwid_api_client = None
hwid_api_cm = None
try:
@@ -1499,19 +1497,8 @@ class RemnaWaveService:
logger.info('🗑️ Деактивация подписки пользователя (нет в панели)', telegram_id=telegram_id)
if db_user.remnawave_uuid and hwid_api_client:
try:
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(
'🔧 Сброшены HWID устройства для пользователя', telegram_id=telegram_id
)
except Exception as hwid_error:
logger.error(
'❌ Ошибка сброса HWID устройств для',
telegram_id=telegram_id,
hwid_error=hwid_error,
)
# NOTE: Не сбрасываем HWID здесь — пользователь уже удалён из панели,
# API вернёт 404, UUID очищается ниже (cleanup_mutation)
try:
from sqlalchemy import delete
+16
View File
@@ -1203,6 +1203,22 @@ class UserService:
logger.error('❌ Ошибка удаления подписки', error=e)
try:
from app.database.models import (
AccessPolicy,
AdminAuditLog,
AdminRole,
UserRole,
WithdrawalRequest,
)
await db.execute(delete(AdminAuditLog).where(AdminAuditLog.user_id == user_id))
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user_id))
await db.execute(
update(WithdrawalRequest).where(WithdrawalRequest.processed_by == user_id).values(processed_by=None)
)
await db.execute(update(AdminRole).where(AdminRole.created_by == user_id).values(created_by=None))
await db.execute(update(UserRole).where(UserRole.assigned_by == user_id).values(assigned_by=None))
await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == user_id).values(created_by=None))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
logger.info('✅ Пользователь окончательно удален из базы', user_id=user_id)
+2 -2
View File
@@ -181,7 +181,7 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
if forced_limit is not None and forced_limit > 0:
_logger.info(
'DEVICES_SELECTION disabled, using forced limit',
forced_limit=forced_limit,
@@ -189,7 +189,7 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
subscription_id=getattr(subscription, 'id', None),
)
return forced_limit
# Если forced_limit не задан, используем device_limit из подписки
# forced_limit не задан или равен 0 — используем device_limit из подписки,
# чтобы при смене тарифа лимит устройств обновлялся в панели
limit = getattr(subscription, 'device_limit', None)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.19.0"
version = "3.20.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }