Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd2e419e8e | |||
| aaf0263fda | |||
| d4d5031cc2 | |||
| b2d7abf5bd | |||
| 0f9f843236 | |||
| cab425cfac | |||
| 0da0c5547d | |||
| 988d0e5c2f | |||
| 1ce91749aa | |||
| 731eb24364 | |||
| a15403b8b6 |
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.19.0"
|
||||
".": "3.20.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+11
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user