Compare commits

...

48 Commits

Author SHA1 Message Date
Egor 37a905f056 Update states.py 2025-08-24 16:58:17 +03:00
Egor bce9061401 Update users.py 2025-08-24 16:57:29 +03:00
Egor 118b282a2b Update states.py 2025-08-24 16:52:11 +03:00
Egor 6c250ed36f Update users.py 2025-08-24 16:51:31 +03:00
Egor 3842aeacca Update subscription_service.py 2025-08-24 16:23:04 +03:00
Egor 1d1afbe1da Update remnawave_service.py 2025-08-24 16:21:54 +03:00
Egor d4db55ea66 Update subscription.py 2025-08-24 16:19:32 +03:00
Egor 354964060c Update README.md 2025-08-23 18:25:49 +03:00
Egor 5bc8d8e52c Update README.md 2025-08-23 18:25:08 +03:00
Egor 8f3e8bacc4 Update README.md 2025-08-23 18:24:38 +03:00
Egor 61c94e17a4 Update promocode_service.py 2025-08-23 16:04:54 +03:00
Egor 77c52c1197 Update README.md 2025-08-23 14:38:43 +03:00
Egor c65c703e00 Update README.md 2025-08-23 12:56:42 +03:00
Egor 7560e802cb Update README.md 2025-08-23 12:53:05 +03:00
Egor a5a90a806b Update README.md 2025-08-23 12:35:37 +03:00
Egor aa0a271d14 Update README.md 2025-08-23 12:31:25 +03:00
Egor 5d4e51c862 Update README.md 2025-08-23 12:16:27 +03:00
Egor e6057bb93c Update .env.example 2025-08-23 11:58:48 +03:00
Egor 714bdd0d7c Update inline.py 2025-08-23 11:52:42 +03:00
Egor 41f3eb76c1 Update subscription.py 2025-08-23 11:51:12 +03:00
Egor e9c58b3f76 Update config.py 2025-08-23 11:48:26 +03:00
Egor 3d6976ff6e Update config.py 2025-08-23 11:44:40 +03:00
Egor ee978dff7b Add files via upload 2025-08-23 11:41:20 +03:00
Egor 0bcf5c5f43 Create 3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-08-23 11:31:06 +03:00
Egor 2ceedd6587 Update README.md 2025-08-23 07:41:34 +03:00
Egor 2ded1a4a9e Update README.md 2025-08-23 07:09:06 +03:00
Egor d551a7267f Update README.md 2025-08-23 06:27:24 +03:00
Egor 3ddf6b35c6 Update .env.example 2025-08-23 06:26:37 +03:00
Egor c0d3c70683 Update inline.py 2025-08-23 06:25:15 +03:00
Egor 7267433d99 Update subscription.py 2025-08-23 06:22:41 +03:00
Egor 09f3c4d27f Update config.py 2025-08-23 06:21:43 +03:00
Egor 3083915bc8 Update remnawave_api.py 2025-08-22 15:18:21 +03:00
Egor 5c72c04d27 Update texts.py 2025-08-22 12:25:42 +03:00
Egor 826149da3d Update balance.py 2025-08-22 12:19:14 +03:00
Egor f090be328f Update menu.py 2025-08-22 11:40:57 +03:00
Egor ecbfcd2b54 Update texts.py 2025-08-22 11:40:27 +03:00
Egor 2d21b1c542 Update balance.py 2025-08-22 11:17:22 +03:00
Egor c63438af5e Update monitoring_service.py 2025-08-22 11:11:52 +03:00
Egor 8e47013ae2 Update texts.py 2025-08-22 11:09:54 +03:00
Egor 92fa0ebcc1 Update inline.py 2025-08-22 11:09:19 +03:00
Egor f1d23616f1 Update balance.py 2025-08-22 11:08:37 +03:00
Egor d554f2d801 Create subscription_notifications.py 2025-08-22 11:07:10 +03:00
Egor 1fe4608604 Update monitoring_service.py 2025-08-22 11:05:41 +03:00
Egor bea534b352 Update README.md 2025-08-22 09:17:46 +03:00
Egor e993c19a34 Update README.md 2025-08-22 09:14:40 +03:00
Egor 2deb5d1bc1 Update README.md 2025-08-22 09:06:46 +03:00
Egor fb1b0bbcd5 Update README.md 2025-08-22 09:02:36 +03:00
Egor 0308dd6c58 Update README.md 2025-08-22 09:02:15 +03:00
18 changed files with 2969 additions and 1006 deletions
+19
View File
@@ -11,6 +11,17 @@ REDIS_URL=redis://localhost:6379/0
REMNAWAVE_API_URL=
REMNAWAVE_API_KEY=
# === NEW: Traffic Selection Mode Settings ===
# Режим выбора трафика:
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
# "fixed" - фиксированный лимит трафика для всех подписок, доступно 5/10/25/50/100/250/0 (0 безлимит) гб
TRAFFIC_SELECTION_MODE=selectable
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
# 0 = безлимит
# для "fixed" обязательно должы быть проставлены цены на пакеты 5/10/25/50/100/250/0 можно постать 0 руб - будет беслпатно
FIXED_TRAFFIC_LIMIT_GB=0
# TRIAL SUBSCRIPTION SETTINGS
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
@@ -43,6 +54,14 @@ REFERRAL_REGISTRATION_REWARD=5000
REFERRED_USER_REWARD=10000
REFERRAL_COMMISSION_PERCENT=25
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
# MINIAPP_CUSTOM_URL=
# AUTO-PAYMENT SETTINGS
AUTOPAY_WARNING_DAYS=3,1
+1117 -603
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+16 -1
View File
@@ -49,6 +49,9 @@ class Settings(BaseSettings):
PRICE_PER_DEVICE: int = 5000
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
REFERRAL_REGISTRATION_REWARD: int = 5000
REFERRED_USER_REWARD: int = 2500
REFERRAL_COMMISSION_PERCENT: int = 10
@@ -69,6 +72,9 @@ class Settings(BaseSettings):
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
@@ -82,7 +88,7 @@ class Settings(BaseSettings):
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600 # 1 час
APP_CONFIG_CACHE_TTL: int = 3600
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -159,6 +165,15 @@ class Settings(BaseSettings):
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8"
+108 -57
View File
@@ -1,7 +1,9 @@
import asyncio
import json
import ssl
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
@@ -85,13 +87,65 @@ class RemnaWaveAPI:
self.api_key = api_key
self.session: Optional[aiohttp.ClientSession] = None
def _detect_connection_type(self) -> str:
parsed = urlparse(self.base_url)
local_hosts = [
'localhost', '127.0.0.1', 'remnawave',
'remnawave-backend', 'app', 'api'
]
if parsed.hostname in local_hosts:
return "local"
if parsed.hostname:
if (parsed.hostname.startswith('192.168.') or
parsed.hostname.startswith('10.') or
parsed.hostname.startswith('172.') or
parsed.hostname.endswith('.local')):
return "local"
return "external"
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
connector_kwargs = {}
if conn_type == "local":
logger.debug("🏠 Использую локальные заголовки proxy")
headers.update({
'X-Forwarded-For': '127.0.0.1',
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'localhost',
'X-Real-IP': '127.0.0.1',
'Host': 'localhost'
})
if self.base_url.startswith('https://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector_kwargs['ssl'] = ssl_context
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
elif conn_type == "external":
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
pass
connector = aiohttp.TCPConnector(**connector_kwargs)
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
headers={
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
headers=headers,
connector=connector
)
return self
@@ -375,6 +429,56 @@ class RemnaWaveAPI:
return response['response']
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
return RemnaWaveUser(
uuid=user_data['uuid'],
@@ -422,7 +526,6 @@ class RemnaWaveAPI:
)
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
@@ -467,55 +570,3 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
except Exception as e:
logger.error(f"API connection test failed: {e}")
return False
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
+668 -3
View File
@@ -860,6 +860,606 @@ async def show_user_statistics(
)
await callback.answer()
@admin_required
@error_handler
async def extend_user_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(extending_user_id=user_id)
await callback.message.edit_text(
"⏰ <b>Продление подписки</b>\n\n"
"Введите количество дней для продления:\n"
"• Например: 30, 7, 90\n"
"• Максимум: 365 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
],
[
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_extend_days_{user_id}_90"),
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_extend_days_{user_id}_180")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.extending_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка продления подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("extending_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 365:
await message.answer("❌ Количество дней должно быть от 1 до 365")
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка продления подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
@admin_required
@error_handler
async def add_subscription_traffic(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(traffic_user_id=user_id)
await callback.message.edit_text(
"📊 <b>Добавление трафика</b>\n\n"
"Введите количество ГБ для добавления:\n"
"• Например: 50, 100, 500\n"
"• Максимум: 10000 ГБ\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_50"),
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_100")
],
[
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_500"),
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_1000")
],
[
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_sub_traffic_add_{user_id}_0"),
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.adding_traffic)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
gb = int(parts[-1])
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await callback.message.edit_text(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка добавления трафика",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("traffic_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
gb = int(message.text.strip())
if gb < 0 or gb > 10000:
await message.answer("❌ Количество ГБ должно быть от 0 до 10000 (0 = безлимит)")
return
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await message.answer(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка добавления трафика")
except ValueError:
await message.answer("❌ Введите корректное число ГБ")
return
await state.clear()
@admin_required
@error_handler
async def deactivate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
await callback.message.edit_text(
"🚫 <b>Деактивация подписки</b>\n\n"
"Вы уверены, что хотите деактивировать подписку этого пользователя?\n"
"Пользователь потеряет доступ к сервису.",
reply_markup=get_confirmation_keyboard(
f"admin_sub_deactivate_confirm_{user_id}",
f"admin_user_subscription_{user_id}",
db_user.language
)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_subscription_deactivation(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _deactivate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя деактивирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка деактивации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def activate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _activate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя активирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка активации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_trial_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _grant_trial_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Пользователю выдан триальный период",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи триального периода",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_paid_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(granting_user_id=user_id)
await callback.message.edit_text(
"💎 <b>Выдача подписки</b>\n\n"
"Введите количество дней подписки:\n"
"• Например: 30, 90, 180, 365\n"
"• Максимум: 730 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_grant_days_{user_id}_30"),
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_grant_days_{user_id}_90")
],
[
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_grant_days_{user_id}_180"),
types.InlineKeyboardButton(text="365 дней", callback_data=f"admin_sub_grant_days_{user_id}_365")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.granting_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("granting_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 730:
await message.answer("❌ Количество дней должно быть от 1 до 730")
return
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка выдачи подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await extend_subscription(db, subscription, days)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
return True
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
return False
async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, add_subscription_traffic
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
if gb == 0:
subscription.traffic_limit_gb = 0
await db.commit()
else:
await add_subscription_traffic(db, subscription, gb)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
traffic_text = "безлимитный" if gb == 0 else f"{gb} ГБ"
logger.info(f"Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
return False
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, deactivate_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"Админ {admin_id} деактивировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка деактивации подписки: {e}")
return False
async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.database.models import SubscriptionStatus
from datetime import datetime
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date <= datetime.utcnow():
subscription.end_date = datetime.utcnow() + timedelta(days=1)
await db.commit()
await db.refresh(subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} активировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка активации подписки: {e}")
return False
async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_trial_subscription
from app.services.subscription_service import SubscriptionService
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_trial_subscription(db, user_id)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал триальную подписку пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи триальной подписки: {e}")
return False
async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.services.subscription_service import SubscriptionService
from app.config import settings
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи платной подписки: {e}")
return False
@admin_required
@error_handler
async def cleanup_inactive_users(
@@ -925,7 +1525,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
confirm_user_block,
F.data.startswith("admin_user_block_")
F.data.startswith("admin_user_block_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
@@ -940,7 +1540,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
confirm_user_delete,
F.data.startswith("admin_user_delete_")
F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
@@ -973,7 +1573,6 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
dp.callback_query.register(
show_inactive_users,
F.data == "admin_users_inactive"
@@ -983,3 +1582,69 @@ def register_handlers(dp: Dispatcher):
cleanup_inactive_users,
F.data == "admin_cleanup_inactive"
)
dp.callback_query.register(
extend_user_subscription,
F.data.startswith("admin_sub_extend_") & ~F.data.contains("days") & ~F.data.contains("confirm")
)
dp.callback_query.register(
process_subscription_extension_days,
F.data.startswith("admin_sub_extend_days_")
)
dp.message.register(
process_subscription_extension_text,
AdminStates.extending_subscription
)
dp.callback_query.register(
add_subscription_traffic,
F.data.startswith("admin_sub_traffic_") & ~F.data.contains("add")
)
dp.callback_query.register(
process_traffic_addition_button,
F.data.startswith("admin_sub_traffic_add_")
)
dp.message.register(
process_traffic_addition_text,
AdminStates.adding_traffic
)
dp.callback_query.register(
deactivate_user_subscription,
F.data.startswith("admin_sub_deactivate_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
confirm_subscription_deactivation,
F.data.startswith("admin_sub_deactivate_confirm_")
)
dp.callback_query.register(
activate_user_subscription,
F.data.startswith("admin_sub_activate_")
)
dp.callback_query.register(
grant_trial_subscription,
F.data.startswith("admin_sub_grant_trial_")
)
dp.callback_query.register(
grant_paid_subscription,
F.data.startswith("admin_sub_grant_") & ~F.data.contains("trial") & ~F.data.contains("days")
)
dp.callback_query.register(
process_subscription_grant_days,
F.data.startswith("admin_sub_grant_days_")
)
dp.message.register(
process_subscription_grant_text,
AdminStates.granting_subscription
)
+130 -99
View File
@@ -96,7 +96,8 @@ async def show_balance_history(
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@@ -112,16 +113,43 @@ async def handle_balance_history_pagination(
@error_handler
async def start_balance_topup(
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
payment_text = """
💳 <b>Способы пополнения баланса</b>
Выберите удобный для вас способ оплаты:
⭐ <b>Telegram Stars</b> - быстро и удобно
💎 <b>Банковская карта</b> - через Tribute
🛠️ <b>Через поддержку</b> - другие способы
Выберите способ пополнения:
"""
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""Начать пополнение - только для Telegram Stars"""
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение временно недоступно", show_alert=True)
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
await callback.message.edit_text(
@@ -130,96 +158,15 @@ async def start_balance_topup(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
"""Обработка введенной суммы - только для Telegram Stars"""
texts = get_texts(db_user.language)
try:
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
await state.update_data(amount_kopeks=amount_kopeks)
payment_text = texts.TOP_UP_METHODS.format(
amount=texts.format_price(amount_kopeks)
)
await message.answer(
payment_text,
reply_markup=get_payment_methods_keyboard(amount_kopeks, db_user.language)
)
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Оплата Stars временно недоступна", show_alert=True)
return
amount_kopeks = int(callback.data.split('_')[-1])
try:
payment_service = PaymentService(callback.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
@error_handler
async def process_tribute_quick_payment(
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
):
"""Быстрое пополнение через Tribute - без выбора суммы"""
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
@@ -242,17 +189,19 @@ async def process_tribute_quick_payment(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 50 \n"
f"• Введите любую сумму от 100\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
@@ -281,6 +230,11 @@ async def request_support_topup(
• Способ оплаты
⏰ Время обработки: 1-24 часа
<b>Доступные способы:</b>
• Криптовалюта
• Переводы между банками
• Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
@@ -288,16 +242,93 @@ async def request_support_topup(
text="💬 Написать в поддержку",
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
else:
await message.answer("❌ Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("❌ Оплата Stars временно недоступна")
return
try:
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("❌ Ошибка создания платежа")
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -316,26 +347,26 @@ def register_handlers(dp: Dispatcher):
)
dp.callback_query.register(
start_balance_topup,
show_payment_methods,
F.data == "balance_topup"
)
dp.callback_query.register(
process_stars_payment,
F.data.startswith("pay_stars_")
start_stars_payment,
F.data == "topup_stars"
)
dp.callback_query.register(
process_tribute_quick_payment,
F.data == "tribute_quick_pay"
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "balance_support"
F.data == "topup_support"
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
)
+15 -5
View File
@@ -108,15 +108,25 @@ async def handle_back_to_menu(
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return texts.SUBSCRIPTION_NONE
return "❌ Отсутствует"
if user.subscription.is_trial:
return f"{texts.SUBSCRIPTION_TRIAL} (до {user.subscription.end_date.strftime('%d.%m.%Y')})"
days_left = user.subscription.days_left
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {user.subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
elif user.subscription.is_active:
days_left = user.subscription.days_left
return f"{texts.SUBSCRIPTION_ACTIVE} ({days_left} дн.)"
if days_left > 7:
return f"✅ Активна\n📅 до {user.subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 0:
return f"✅ Активна\n⚠️ истекает через {days_left} дн."
else:
return f"✅ Активна\n⚠️ истекает сегодня!"
else:
return texts.SUBSCRIPTION_EXPIRED
return f"⏰ Истекла\n📅 {user.subscription.end_date.strftime('%d.%m.%Y')}"
def register_handlers(dp: Dispatcher):
@@ -129,4 +139,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_service_rules,
F.data == "menu_rules"
)
)
+295 -96
View File
@@ -6,7 +6,7 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
from typing import Dict, List, Any, Tuple
from typing import Dict, List, Any, Tuple, Optional
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.states import SubscriptionStates
@@ -67,49 +67,7 @@ async def show_subscription_info(
await db.refresh(subscription)
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
else:
status_text = "❌ Истекла"
type_text = "Платная подписка"
if subscription.traffic_limit_gb == 0:
traffic_text = "∞ (безлимит)"
else:
traffic_text = texts.format_traffic(subscription.traffic_limit_gb)
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "❌ Выключен"
)
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки:</b> {texts.format_price(subscription_cost)}"
if subscription_url and subscription_url != "Генерируется...":
info_text += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>"
info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
info_text = await get_subscription_info_text(subscription, texts, db_user, db)
await callback.message.edit_text(
info_text,
@@ -289,7 +247,6 @@ async def start_subscription_purchase(
state: FSMContext,
db_user: User
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
@@ -297,14 +254,19 @@ async def start_subscription_purchase(
reply_markup=get_subscription_period_keyboard(db_user.language)
)
await state.set_data({
initial_data = {
'period_days': None,
'traffic_gb': None,
'countries': [],
'devices': 1,
'total_price': 0
})
}
if settings.is_traffic_fixed():
initial_data['traffic_gb'] = settings.get_fixed_traffic_limit()
else:
initial_data['traffic_gb'] = None
await state.set_data(initial_data)
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
@@ -575,16 +537,21 @@ async def handle_add_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
@@ -672,22 +639,27 @@ async def handle_reset_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(" У вас безлимитный трафик", show_alert=True)
await callback.answer(" У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
await callback.message.edit_text(
@@ -708,6 +680,11 @@ async def confirm_add_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -716,7 +693,7 @@ async def confirm_add_traffic(
price = TRAFFIC_PRICES[traffic_gb]
if db_user.balance_kopeks < price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
try:
@@ -726,7 +703,7 @@ async def confirm_add_traffic(
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
if traffic_gb == 0:
@@ -959,6 +936,11 @@ async def confirm_reset_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -966,7 +948,7 @@ async def confirm_reset_traffic(
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
try:
@@ -976,7 +958,7 @@ async def confirm_reset_traffic(
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
subscription.traffic_used_gb = 0.0
@@ -1027,23 +1009,102 @@ async def select_period(
state: FSMContext,
db_user: User
):
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = PERIOD_PRICES[period_days]
if settings.is_traffic_fixed():
fixed_traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
data['total_price'] += fixed_traffic_price
await state.set_data(data)
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
countries = await _get_available_countries()
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
await state.set_state(SubscriptionStates.selecting_traffic)
await callback.answer()
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
else:
status_text = "⌛ Истекла"
type_text = "Платная подписка"
if subscription.traffic_limit_gb == 0:
if settings.is_traffic_fixed():
traffic_text = "∞ Безлимитный"
else:
traffic_text = "∞ Безлимитный"
else:
if settings.is_traffic_fixed():
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
else:
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
)
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки:</b> {texts.format_price(subscription_cost)}"
if subscription_url and subscription_url != "Генерируется...":
info_text += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>"
info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
return info_text
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
if is_fixed_mode is None:
is_fixed_mode = settings.is_traffic_fixed()
if traffic_gb == 0:
if is_fixed_mode:
return "Безлимитный"
else:
return "Безлимитный"
else:
if is_fixed_mode:
return f"{traffic_gb} ГБ"
else:
return f"{traffic_gb} ГБ"
async def select_traffic(
callback: types.CallbackQuery,
@@ -1168,16 +1229,14 @@ async def select_devices(
)
await callback.answer()
async def devices_continue(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if not callback.data == "devices_continue":
await callback.answer(" Некорректный запрос", show_alert=True)
await callback.answer(" Некорректный запрос", show_alert=True)
return
data = await state.get_data()
@@ -1197,24 +1256,47 @@ async def devices_continue(
if country['uuid'] in data['countries']:
selected_countries_names.append(country['name'])
base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']]
base_price = PERIOD_PRICES[data['period_days']]
if settings.is_traffic_fixed():
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
else:
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
total_price = base_price + countries_price + devices_price
total_price = base_price + traffic_price + countries_price + devices_price
data['total_price'] = total_price
await state.set_data(data)
summary_text = texts.SUBSCRIPTION_SUMMARY.format(
period=data['period_days'],
traffic=texts.format_traffic(data['traffic_gb']),
countries=", ".join(selected_countries_names),
devices=data['devices'],
total_price=texts.format_price(total_price)
)
if settings.is_traffic_fixed():
if data['traffic_gb'] == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{data['traffic_gb']} ГБ"
else:
if data['traffic_gb'] == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{data['traffic_gb']} ГБ"
summary_text = f"""
📋 <b>Сводка заказа</b>
📅 <b>Период:</b> {data['period_days']} дней
📊 <b>Трафик:</b> {traffic_display}
🌍 <b>Страны:</b> {", ".join(selected_countries_names)}
📱 <b>Устройства:</b> {data['devices']}
💰 <b>Общая стоимость:</b> {texts.format_price(total_price)}
Подтверждаете покупку?
"""
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language)
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.confirming_purchase)
@@ -1232,7 +1314,7 @@ async def confirm_purchase(
countries = await _get_available_countries()
base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']]
base_price = PERIOD_PRICES[data['period_days']]
countries_price = 0
server_prices = []
@@ -1242,7 +1324,15 @@ async def confirm_purchase(
server_prices.append(country['price_kopeks'])
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
final_price = base_price + countries_price + devices_price
if settings.is_traffic_fixed():
traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
final_traffic_gb = settings.get_fixed_traffic_limit()
else:
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
final_traffic_gb = data['traffic_gb']
final_price = base_price + traffic_price + countries_price + devices_price
if db_user.balance_kopeks < final_price:
await callback.message.edit_text(
@@ -1274,7 +1364,7 @@ async def confirm_purchase(
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
existing_subscription.traffic_limit_gb = data['traffic_gb']
existing_subscription.traffic_limit_gb = final_traffic_gb
existing_subscription.device_limit = data['devices']
existing_subscription.connected_squads = data['countries']
@@ -1289,13 +1379,13 @@ async def confirm_purchase(
else:
logger.info(f"🆕 Создаем новую платную подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription(
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
duration_days=data['period_days'],
traffic_limit_gb=data['traffic_gb'],
device_limit=data['devices'],
connected_squads=data['countries']
connected_squads=data['countries'],
traffic_gb=final_traffic_gb
)
from app.utils.user_utils import mark_user_as_had_paid_subscription
@@ -1317,9 +1407,16 @@ async def confirm_purchase(
subscription_service = SubscriptionService()
if existing_subscription and existing_subscription.is_trial == False:
if db_user.remnawave_uuid:
logger.info(f"📝 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
else:
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if not remnawave_user:
logger.error(f"❌ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
logger.info(f"🔄 Fallback: принудительное создание нового RemnaWave пользователя")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
await create_transaction(
@@ -1384,6 +1481,39 @@ async def confirm_purchase(
await state.clear()
await callback.answer()
async def create_paid_subscription_with_traffic_mode(
db: AsyncSession,
user_id: int,
duration_days: int,
device_limit: int,
connected_squads: List[str],
traffic_gb: Optional[int] = None
):
from app.config import settings
from app.database.crud.subscription import create_paid_subscription
if traffic_gb is None:
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
traffic_limit_gb = 0
else:
traffic_limit_gb = traffic_gb
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads
)
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
return subscription
async def handle_subscription_settings(
callback: types.CallbackQuery,
db_user: User,
@@ -1495,7 +1625,6 @@ async def handle_subscription_config_back(
db_user: User,
db: AsyncSession
):
current_state = await state.get_state()
texts = get_texts(db_user.language)
@@ -1507,11 +1636,18 @@ async def handle_subscription_config_back(
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_countries.state:
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
texts.BUY_SUBSCRIPTION_START,
reply_markup=get_subscription_period_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
countries = await _get_available_countries()
@@ -1946,20 +2082,83 @@ async def handle_connect_subscription(
await callback.answer("❌ У вас нет активной подписки или ссылка еще генерируется", show_alert=True)
return
device_text = f"""
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Открыть подписку",
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
],
[
InlineKeyboardButton(text="📋 Показать ссылку", callback_data="open_subscription_link")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
await callback.message.edit_text(
f"""
🔗 <b>Подключить подписку</b>
📱 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:
""",
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
await callback.answer("❌ Кастомная ссылка для мини-приложения не настроена", show_alert=True)
return
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Открыть приложение",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
],
[
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
await callback.message.edit_text(
f"""
🚀 <b>Подключить подписку</b>
📱 Нажмите кнопку ниже, чтобы открыть приложение:
📋 <b>Ссылка подписки:</b>
<code>{subscription.subscription_url}</code>
""",
reply_markup=keyboard,
parse_mode="HTML"
)
else:
device_text = f"""
📱 <b>Подключить подписку</b>
🔗 <b>Ссылка подписки:</b>
<code>{subscription.subscription_url}</code>
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:
"""
"""
await callback.message.edit_text(
device_text,
reply_markup=get_device_selection_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.message.edit_text(
device_text,
reply_markup=get_device_selection_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
+171
View File
@@ -0,0 +1,171 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from app.database.models import User, Subscription
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
async def send_subscription_expiring_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
days_left: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
if subscription.is_trial:
text = texts.TRIAL_ENDING_SOON.format(
price=texts.format_price(30000)
)
else:
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
if subscription.autopay_enabled:
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
days=days_left,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
autopay_status=autopay_status,
action_text=action_text
)
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
return False
async def send_autopay_failed_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
required_amount: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
text = texts.AUTOPAY_FAILED.format(
balance=texts.format_price(user.balance_kopeks),
required=texts.format_price(required_amount)
)
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
return False
async def process_autopayment(
bot: Bot,
db: AsyncSession,
subscription: Subscription
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
return False
subscription_service = SubscriptionService()
renewal_cost = await subscription_service.calculate_renewal_price(
subscription, 30, db
)
if user.balance_kopeks < renewal_cost:
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
success = await subtract_user_balance(
db, user, renewal_cost,
f"Автопродление подписки на 30 дней"
)
if not success:
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
await extend_subscription(db, subscription, 30)
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description="Автопродление подписки на 30 дней"
)
texts = get_texts(user.language)
success_text = texts.AUTOPAY_SUCCESS.format(
days=30,
amount=texts.format_price(renewal_cost),
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
parse_mode="HTML"
)
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
return False
+126 -39
View File
@@ -97,13 +97,24 @@ def get_subscription_keyboard(
is_trial: bool = False,
subscription=None
) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if has_subscription:
if subscription and subscription.subscription_url:
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
button_text = "🚀 Открыть в мини-приложении"
elif connect_mode == "miniapp_custom":
button_text = "🚀 Открыть приложение"
else:
button_text = "🔗 Подключиться"
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
InlineKeyboardButton(text=button_text, callback_data="subscription_connect")
])
if not is_trial and subscription and subscription.days_left <= 3:
@@ -132,28 +143,33 @@ def get_subscription_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_subscription_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
],
[
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
],
[
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
],
[
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
],
[
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
]
if settings.is_traffic_selectable():
keyboard.insert(1, [
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
])
keyboard.insert(2, [
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -193,6 +209,11 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -292,25 +313,16 @@ def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMar
def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
keyboard.append([
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(text="💳 Быстрое пополнение", callback_data="tribute_quick_pay")
])
keyboard.append([
InlineKeyboardButton(text=texts.BALANCE_SUPPORT_REQUEST, callback_data="balance_support")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
keyboard = [
[
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -321,16 +333,74 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(text=texts.TOP_UP_STARS, callback_data=f"pay_stars_{amount_kopeks}")
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта",
callback_data="topup_tribute"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
InlineKeyboardButton(
text="🛠️ Через поддержку",
callback_data="topup_support"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)
]
])
def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="⏰ Продлить подписку",
callback_data="subscription_extend"
)
],
[
InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)
]
])
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -479,6 +549,11 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -528,6 +603,11 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> Inli
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -537,7 +617,7 @@ def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru")
)
],
[
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
]
])
@@ -605,25 +685,32 @@ def get_manage_countries_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
from app.config import settings
keyboard = [
[
InlineKeyboardButton(text="📱 iOS (iPhone/iPad)", callback_data="device_guide_ios"),
InlineKeyboardButton(text="🤖 Android", callback_data="device_guide_android")
],
[
InlineKeyboardButton(text="💻 Windows", callback_data="device_guide_windows"),
InlineKeyboardButton(text="🍎 macOS", callback_data="device_guide_mac")
InlineKeyboardButton(text="🎯 macOS", callback_data="device_guide_mac")
],
[
InlineKeyboardButton(text="📺 Android TV", callback_data="device_guide_tv")
],
[
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
]
if settings.CONNECT_BUTTON_MODE == "guide":
keyboard.append([
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_connection_guide_keyboard(
+4 -4
View File
@@ -106,11 +106,11 @@ class RussianTexts(Texts):
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """
👤 <b>{user_name}</b>
MAIN_MENU = """👤 <b>{user_name}</b>
💰 <b>Баланс:</b> {balance}
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
@@ -268,7 +268,7 @@ class RussianTexts(Texts):
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
+77 -53
View File
@@ -32,7 +32,7 @@ class MonitoringService:
self.subscription_service = SubscriptionService()
self.payment_service = PaymentService()
self.bot = bot
self._notified_users: Set[str] = set() # Защита от дублирования уведомлений
self._notified_users: Set[str] = set()
async def start_monitoring(self):
if self.is_running:
@@ -60,12 +60,11 @@ class MonitoringService:
try:
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db) # Новый метод!
await self._check_trial_expiring_soon(db)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
# Очищаем кеш уведомлений каждые 24 часа
current_hour = datetime.utcnow().hour
if current_hour == 0:
self._notified_users.clear()
@@ -88,7 +87,6 @@ class MonitoringService:
break
async def _check_expired_subscriptions(self, db: AsyncSession):
"""Проверка истекших подписок"""
try:
expired_subscriptions = await get_expired_subscriptions(db)
@@ -99,7 +97,6 @@ class MonitoringService:
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
# Отправляем уведомление об истечении
if user and self.bot:
await self._send_subscription_expired_notification(user)
@@ -116,12 +113,10 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекших подписок: {e}")
async def _check_expiring_subscriptions(self, db: AsyncSession):
"""Проверка подписок, истекающих через 2-3 дня (только платные)"""
try:
warning_days = settings.get_autopay_warning_days()
for days in warning_days:
# Получаем только платные подписки
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
for subscription in expiring_subscriptions:
@@ -131,7 +126,7 @@ class MonitoringService:
notification_key = f"expiring_{user.telegram_id}_{days}d"
if notification_key in self._notified_users:
continue # Уже уведомляли сегодня
continue
if self.bot:
await self._send_subscription_expiring_notification(user, subscription, days)
@@ -150,9 +145,7 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекающих подписок: {e}")
async def _check_trial_expiring_soon(self, db: AsyncSession):
"""Проверка тестовых подписок, истекающих через 2 часа"""
try:
# Получаем тестовые подписки, истекающие через 2 часа
threshold_time = datetime.utcnow() + timedelta(hours=2)
result = await db.execute(
@@ -176,7 +169,7 @@ class MonitoringService:
notification_key = f"trial_2h_{user.telegram_id}"
if notification_key in self._notified_users:
continue # Уже уведомляли
continue
if self.bot:
await self._send_trial_ending_notification(user, subscription)
@@ -195,7 +188,6 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
"""Получение платных подписок, истекающих через указанное количество дней"""
threshold_date = datetime.utcnow() + timedelta(days=days_before)
result = await db.execute(
@@ -204,7 +196,7 @@ class MonitoringService:
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False, # Только платные
Subscription.is_trial == False,
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.utcnow()
)
@@ -213,9 +205,7 @@ class MonitoringService:
return result.scalars().all()
async def _process_autopayments(self, db: AsyncSession):
"""Обработка автоплатежей"""
try:
# Исправленный запрос с использованием индивидуальных настроек
current_time = datetime.utcnow()
result = await db.execute(
@@ -225,13 +215,12 @@ class MonitoringService:
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False # Автооплата только для платных
Subscription.is_trial == False
)
)
)
all_autopay_subscriptions = result.scalars().all()
# Фильтруем по времени с учетом индивидуальных настроек
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
days_before_expiry = (sub.end_date - current_time).days
@@ -248,24 +237,20 @@ class MonitoringService:
renewal_cost = settings.PRICE_30_DAYS
# Проверяем, не списывали ли уже сегодня
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
# Списываем средства
success = await subtract_user_balance(
db, user, renewal_cost,
"Автопродление подписки"
)
if success:
# Продлеваем подписку
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
# Уведомляем об успешном автоплатеже
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
@@ -279,7 +264,6 @@ class MonitoringService:
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
# Уведомляем о недостатке средств
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
@@ -294,54 +278,98 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
# Методы отправки уведомлений
async def _send_subscription_expired_notification(self, user: User):
"""Уведомление об истечении подписки"""
try:
texts = get_texts(user.language)
message = texts.SUBSCRIPTION_EXPIRED
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
message = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
"""Уведомление об истечении подписки через N дней"""
try:
texts = get_texts(user.language)
message = texts.SUBSCRIPTION_EXPIRING.format(days=days)
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days} дней!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
"""Уведомление об окончании тестовой подписки через 2 часа"""
try:
texts = get_texts(user.language)
# Создаем специальное сообщение для тестовой подписки
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
Успейте оформить до окончания тестового периода!
"""
# Добавляем inline клавиатуру с кнопкой покупки
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="buy_subscription")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
@@ -355,7 +383,6 @@ class MonitoringService:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
"""Уведомление об успешном автоплатеже"""
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_SUCCESS.format(
@@ -367,7 +394,6 @@ class MonitoringService:
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
"""Уведомление о неудачном автоплатеже"""
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_FAILED.format(
@@ -375,11 +401,11 @@ class MonitoringService:
required=settings.format_price(required)
)
# Добавляем кнопку пополнения баланса
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
@@ -392,7 +418,6 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
# Остальные методы остаются без изменений...
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.utcnow()
@@ -523,7 +548,6 @@ class MonitoringService:
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
try:
# Проверяем истекшие
expired_subscriptions = await get_expired_subscriptions(db)
expired_count = 0
@@ -618,4 +642,4 @@ class MonitoringService:
return 0
monitoring_service = MonitoringService()
monitoring_service = MonitoringService()
+36 -9
View File
@@ -86,32 +86,59 @@ class PromoCodeService:
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}")
if promocode.subscription_days > 0:
from app.database.crud.subscription import create_paid_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
await extend_subscription(db, subscription, promocode.subscription_days)
await self.subscription_service.update_remnawave_user(db, subscription)
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
else:
await create_paid_subscription(
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
traffic_limit_gb=0,
device_limit=1,
connected_squads=[]
connected_squads=trial_squads
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
from app.database.crud.subscription import create_trial_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription and not user.has_had_paid_subscription:
await create_trial_subscription(db, user.id)
effects.append("🎁 Активирована тестовая подписка")
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
else:
effects.append("Тестовая подписка уже недоступна")
effects.append("У вас уже есть активная подписка")
return "\n".join(effects) if effects else "✅ Промокод активирован"
return "\n".join(effects) if effects else "✅ Промокод активирован"
+49 -35
View File
@@ -391,34 +391,22 @@ class RemnaWaveService:
return False
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
"""
Синхронизация пользователей из панели RemnaWave в бота
sync_type:
- "all": полная синхронизация (создание + обновление + удаление)
- "new_only": только создание новых пользователей
- "update_only": только обновление существующих
"""
try:
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
async with self.api as api:
# Получаем всех пользователей из панели
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}")
# Получаем всех пользователей из бота для сравнения
bot_users = await get_users_list(db, offset=0, limit=10000)
bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users}
# Множество telegram_id из панели для проверки удаленных
panel_telegram_ids = set()
# Обрабатываем каждого пользователя из панели
for i, panel_user in enumerate(panel_users):
try:
telegram_id = panel_user.get('telegramId')
@@ -433,7 +421,6 @@ class RemnaWaveService:
db_user = bot_users_by_telegram_id.get(telegram_id)
if not db_user:
# Пользователя нет в боте - создаем
if sync_type in ["new_only", "all"]:
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
@@ -455,7 +442,6 @@ class RemnaWaveService:
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
else:
# Пользователь есть в боте - обновляем
if sync_type in ["update_only", "all"]:
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
@@ -472,25 +458,64 @@ class RemnaWaveService:
stats["errors"] += 1
continue
# Удаляем подписки пользователей, которых нет в панели
if sync_type == "all":
logger.info("🗑️ Удаляем подписки пользователей, отсутствующих в панели...")
logger.info("🗑️ Полная очистка подписок пользователей, отсутствующих в панели...")
for telegram_id, db_user in bot_users_by_telegram_id.items():
if telegram_id not in panel_telegram_ids and db_user.subscription:
try:
logger.info(f"🗑️ Удаляем подписку пользователя {telegram_id} (нет в панели)")
logger.info(f"🗑️ Полная очистка данных подписки пользователя {telegram_id} (нет в панели)")
# Деактивируем подписку
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, db_user.subscription)
subscription = db_user.subscription
if db_user.remnawave_uuid:
try:
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
else:
logger.warning(f"⚠️ Не удалось сбросить HWID устройства для пользователя {telegram_id}")
except Exception as hwid_error:
logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}")
from app.database.models import SubscriptionStatus
from datetime import datetime
subscription.status = SubscriptionStatus.DISABLED.value
subscription.is_trial = True
subscription.end_date = datetime.utcnow()
subscription.traffic_limit_gb = 0
subscription.traffic_used_gb = 0.0
subscription.device_limit = 1
subscription.connected_squads = []
subscription.autopay_enabled = False
subscription.autopay_days_before = 3
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
db_user.remnawave_uuid = None
try:
from app.database.crud.subscription import get_subscription_server_ids, remove_subscription_servers
server_ids = await get_subscription_server_ids(db, subscription.id)
if server_ids:
await remove_subscription_servers(db, subscription.id, server_ids)
logger.info(f"🗑️ Удалены серверы подписки ({len(server_ids)}): {server_ids}")
except Exception as servers_error:
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
await db.commit()
stats["deleted"] += 1
logger.info(f"Деактивирована подписка пользователя {telegram_id}")
logger.info(f"ПОЛНОСТЬЮ очищены ВСЕ данные подписки пользователя {telegram_id}")
except Exception as delete_error:
logger.error(f"❌ Ошибка удаления подписки {telegram_id}: {delete_error}")
logger.error(f"❌ Ошибка полной очистки данных подписки {telegram_id}: {delete_error}")
stats["errors"] += 1
await db.rollback()
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, удалено {stats['deleted']}, ошибок {stats['errors']}")
return stats
@@ -599,7 +624,6 @@ class RemnaWaveService:
await self._create_subscription_from_panel_data(db, user, panel_user)
return
# Обновляем статус подписки
panel_status = panel_user.get('status', 'ACTIVE')
expire_at_str = panel_user.get('expireAt', '')
@@ -611,14 +635,12 @@ class RemnaWaveService:
if expire_at.tzinfo is not None:
expire_at = expire_at.replace(tzinfo=None)
# Обновляем дату окончания если она отличается
if abs((subscription.end_date - expire_at).total_seconds()) > 60: # больше минуты разницы
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
except Exception as date_error:
logger.warning(f"⚠️ Ошибка парсинга даты при обновлении {expire_at_str}: {date_error}")
# Обновляем статус
current_time = datetime.utcnow()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
@@ -627,13 +649,12 @@ class RemnaWaveService:
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
else:
new_status = subscription.status # Оставляем текущий статус
new_status = subscription.status
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
# Обновляем использованный трафик
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
traffic_used_gb = used_traffic_bytes / (1024**3)
@@ -641,7 +662,6 @@ class RemnaWaveService:
subscription.traffic_used_gb = traffic_used_gb
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
# Обновляем лимит трафика
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
@@ -649,22 +669,18 @@ class RemnaWaveService:
subscription.traffic_limit_gb = traffic_limit_gb
logger.debug(f"Обновлен лимит трафика: {traffic_limit_gb} GB")
# Обновляем лимит устройств
device_limit = panel_user.get('hwidDeviceLimit', 1) or 1
if subscription.device_limit != device_limit:
subscription.device_limit = device_limit
logger.debug(f"Обновлен лимит устройств: {device_limit}")
# Обновляем RemnaWave UUID если отсутствует
if not subscription.remnawave_short_uuid:
subscription.remnawave_short_uuid = panel_user.get('shortUuid')
# Обновляем URL подписки если отсутствует или изменился
panel_url = panel_user.get('subscriptionUrl', '')
if not subscription.subscription_url or subscription.subscription_url != panel_url:
subscription.subscription_url = panel_url
# Обновляем подключенные сквады
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
@@ -674,7 +690,6 @@ class RemnaWaveService:
elif isinstance(squad, str):
squad_uuids.append(squad)
# Сравниваем сквады - обновляем только если есть изменения
current_squads = set(subscription.connected_squads or [])
new_squads = set(squad_uuids)
@@ -729,7 +744,6 @@ class RemnaWaveService:
active_internal_squads=subscription.connected_squads
)
# Обновляем UUID в нашей базе
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
await db.commit()
+64 -1
View File
@@ -34,10 +34,23 @@ class SubscriptionService:
logger.error(f"Пользователь {subscription.user_id} не найден")
return None
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
remnawave_user = existing_users[0]
try:
await api.reset_user_devices(remnawave_user.uuid)
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
updated_user = await api.update_user(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
@@ -47,7 +60,9 @@ class SubscriptionService:
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
username=username,
@@ -67,7 +82,7 @@ class SubscriptionService:
await db.commit()
logger.info(f"✅ Создан RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
@@ -267,6 +282,54 @@ class SubscriptionService:
logger.error(f"Ошибка расчета стоимости продления: {e}")
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def validate_and_clean_subscription(
self,
db: AsyncSession,
subscription: Subscription,
user: User
) -> bool:
try:
needs_cleanup = False
if user.remnawave_uuid:
try:
async with self.api as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
logger.warning(f"⚠️ Пользователь {user.telegram_id} имеет UUID {user.remnawave_uuid}, но не найден в панели")
needs_cleanup = True
else:
if remnawave_user.telegram_id != user.telegram_id:
logger.warning(f"⚠️ Несоответствие telegram_id для пользователя {user.telegram_id}")
needs_cleanup = True
except Exception as api_error:
logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}")
needs_cleanup = True
if subscription.remnawave_short_uuid and not user.remnawave_uuid:
logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid")
needs_cleanup = True
if needs_cleanup:
logger.info(f"🧹 Очищаем мусорные данные подписки для пользователя {user.telegram_id}")
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.connected_squads = []
user.remnawave_uuid = None
await db.commit()
logger.info(f"✅ Мусорные данные очищены для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка валидации подписки для пользователя {user.telegram_id}: {e}")
await db.rollback()
return False
async def get_countries_price_by_uuids(
self,
+4 -1
View File
@@ -35,6 +35,9 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()
granting_subscription = State()
editing_user_subscription = State()
creating_promocode = State()
@@ -79,4 +82,4 @@ class SquadCreateStates(StatesGroup):
selecting_inbounds = State()
class SquadRenameStates(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_name = State()
@@ -0,0 +1,29 @@
"""add_paid_price_to_subscription
Revision ID: 3d9b35c6bd8f
Revises:
Create Date: 2025-08-23 08:17:00.563340
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9b35c6bd8f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'paid_price_kopeks')
# ### end Alembic commands ###