Compare commits

...

9 Commits

Author SHA1 Message Date
Fr1ngg 273df23f20 Update README.md 2025-08-07 09:32:39 +03:00
Fr1ngg a9b0dbf3eb Update handlers.py 2025-08-07 09:31:28 +03:00
Fr1ngg 5f56e068d3 Update remnawave_api.py 2025-08-07 09:27:59 +03:00
Fr1ngg 92b82132d8 Update remnawave_api.py 2025-08-07 09:27:42 +03:00
Fr1ngg 79ae9d3a1c Update config.py 2025-08-07 09:25:55 +03:00
Fr1ngg 86714a93f9 Update handlers.py 2025-08-07 09:25:40 +03:00
Fr1ngg 553f6f8cf6 Update handlers.py 2025-08-07 09:24:22 +03:00
Fr1ngg b561f67c12 Update utils.py 2025-08-07 09:23:52 +03:00
Fr1ngg d03f9b80c7 Update translations.py 2025-08-07 09:23:26 +03:00
6 changed files with 476 additions and 260 deletions
-3
View File
@@ -65,7 +65,6 @@ URL и токен RemnaWave API
REMNAWAVE_URL=https://your-remnawave-url.ru
REMNAWAVE_MODE=remote/local
REMNAWAVE_TOKEN=ваш_remnawave_token
SUBSCRIPTION_BASE_URL=https://
DATABASE_URL=sqlite+aiosqlite:///bot.db
ADMIN_IDS=123456789,987654321
DEFAULT_LANGUAGE=ru
@@ -128,8 +127,6 @@ REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
DATABASE_URL — строка подключения к базе данных.
SUBSCRIPTION_BASE_URL=https://sub.example.com (без / на конце)
ADMIN_IDS — через запятую Telegram ID администраторов.
SUPPORT_USERNAME — ник поддержки, без @ указывать
+5 -10
View File
@@ -1,6 +1,6 @@
import os
from dataclasses import dataclass
from typing import List
from typing import List, Optional
@dataclass
class Config:
@@ -13,8 +13,8 @@ class Config:
DEFAULT_LANGUAGE: str
SUPPORT_USERNAME: str
# Subscription URL settings
SUBSCRIPTION_BASE_URL: str
# Subscription URL settings - ТЕПЕРЬ ОПЦИОНАЛЬНЫЙ
SUBSCRIPTION_BASE_URL: Optional[str]
# Trial subscription settings
TRIAL_ENABLED: bool
@@ -39,12 +39,7 @@ def load_config() -> Config:
except ValueError:
admin_ids = []
# Get subscription base URL -
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
if not subscription_base_url:
subscription_base_url = 'https://sub.fring.tech'
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL')
return Config(
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
@@ -56,7 +51,7 @@ def load_config() -> Config:
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
# Subscription URL
# Subscription URL - ТЕПЕРЬ МОЖЕТ БЫТЬ None
SUBSCRIPTION_BASE_URL=subscription_base_url,
# Trial subscription settings
+171 -122
View File
@@ -67,7 +67,7 @@ class BotStates(StatesGroup):
router = Router()
# Start command
# Start command
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, db: Database, **kwargs):
"""Handle /start command"""
@@ -92,7 +92,7 @@ async def start_command(message: Message, state: FSMContext, db: Database, **kwa
else:
await show_main_menu(message, user.language, user.is_admin, user.telegram_id, db, config)
# Language selection
# Language selection
@router.callback_query(F.data.startswith("lang_"))
async def language_callback(callback: CallbackQuery, state: FSMContext, db: Database, **kwargs):
"""Handle language selection"""
@@ -159,7 +159,7 @@ async def show_main_menu(message: Message, lang: str, is_admin: bool = False, us
logger.error(f"Error showing main menu: {e}")
await message.answer("❌ Ошибка отображения меню")
# Main menu handlers
# Main menu handlers
@router.callback_query(F.data == "main_menu")
async def main_menu_callback(callback: CallbackQuery, **kwargs):
"""Return to main menu"""
@@ -186,6 +186,7 @@ async def main_menu_callback(callback: CallbackQuery, **kwargs):
reply_markup=main_menu_keyboard(user.language, user.is_admin, show_trial)
)
# Trial subscription handlers
@router.callback_query(F.data == "trial_subscription")
async def trial_subscription_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Show trial subscription info"""
@@ -222,7 +223,7 @@ async def trial_subscription_callback(callback: CallbackQuery, db: Database, **k
@router.callback_query(F.data == "confirm_trial")
async def confirm_trial_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Confirm and create trial subscription"""
"""Confirm and create trial subscription - ДОБАВЛЕНА ПОДДЕРЖКА URL ИЗ API"""
user = kwargs.get('user')
api = kwargs.get('api')
config = kwargs.get('config')
@@ -312,9 +313,9 @@ async def confirm_trial_callback(callback: CallbackQuery, db: Database, **kwargs
)
return
# Создаем временную тестовую подписку, которая НЕ будет отображаться в админке
# Создаем временную тестовую подписку
trial_subscription = await db.create_subscription(
name=f"Trial_{user.telegram_id}_{int(datetime.utcnow().timestamp())}", # Уникальное имя
name=f"Trial_{user.telegram_id}_{int(datetime.utcnow().timestamp())}",
description="Автоматически созданная тестовая подписка",
price=0,
duration_days=config.TRIAL_DURATION_DAYS,
@@ -349,9 +350,22 @@ async def confirm_trial_callback(callback: CallbackQuery, db: Database, **kwargs
status='completed'
)
# НОВОЕ: Получаем subscription URL и показываем пользователю
success_text = t('trial_success', user.language)
try:
subscription_url = await api.get_subscription_url(short_uuid)
if subscription_url:
success_text += f"\n\n🔗 <a href='{subscription_url}'>Нажмите для подключения</a>"
success_text += f"\n📱 Скопируйте ссылку и импортируйте конфигурацию в ваше VPN приложение"
except Exception as e:
logger.warning(f"Could not get trial subscription URL: {e}")
await callback.message.edit_text(
t('trial_success', user.language),
reply_markup=main_menu_keyboard(user.language, user.is_admin)
success_text,
reply_markup=main_menu_keyboard(user.language, user.is_admin),
parse_mode='HTML',
disable_web_page_preview=True
)
log_user_action(user.telegram_id, "trial_subscription_activated", "Free trial")
@@ -363,6 +377,7 @@ async def confirm_trial_callback(callback: CallbackQuery, db: Database, **kwargs
reply_markup=main_menu_keyboard(user.language, user.is_admin)
)
# Balance handlers
@router.callback_query(F.data == "change_language")
async def change_language_callback(callback: CallbackQuery, **kwargs):
"""Show language selection for changing language"""
@@ -530,7 +545,7 @@ async def payment_history_callback(callback: CallbackQuery, db: Database, **kwar
logger.error(f"Error getting payment history: {e}")
await callback.answer(t('error_occurred', user.language))
# Subscriptions
# Subscription handlers
@router.callback_query(F.data == "buy_subscription")
async def buy_subscription_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Show available subscriptions (excluding trial)"""
@@ -607,7 +622,7 @@ async def buy_subscription_detail(callback: CallbackQuery, db: Database, **kwarg
@router.callback_query(F.data.startswith("confirm_buy_"))
async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
"""Confirm subscription purchase"""
"""Confirm subscription purchase - ДОБАВЛЕНА ПОДДЕРЖКА URL ИЗ API"""
user = kwargs.get('user')
api = kwargs.get('api')
@@ -637,9 +652,7 @@ async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
)
return
# ИСПРАВЛЕНИЕ: Всегда создавать НОВЫЙ пользователь в RemnaWave для каждой подписки
# Это позволит избежать дублирования short_uuid и даст каждой подписке уникальную ссылку
# Создаем нового пользователя в RemnaWave для каждой подписки
username = generate_username()
password = generate_password()
@@ -654,7 +667,7 @@ async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
activeInternalSquads=[subscription.squad_uuid]
)
# Handle API response which may use 'data' or 'response' key
# Handle API response
if remna_user:
if 'data' in remna_user and 'uuid' in remna_user['data']:
user_uuid = remna_user['data']['uuid']
@@ -710,13 +723,13 @@ async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
user.balance -= subscription.price
await db.update_user(user)
# Create user subscription record with the NEW short_uuid
# Create user subscription record
expires_at = datetime.utcnow() + timedelta(days=subscription.duration_days)
await db.create_user_subscription(
user_id=user.telegram_id,
subscription_id=subscription.id,
short_uuid=short_uuid, # Это НОВЫЙ уникальный shortUuid
short_uuid=short_uuid,
expires_at=expires_at
)
@@ -729,9 +742,29 @@ async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
status='completed'
)
# Формируем сообщение с URL из API
success_text = f"✅ Подписка успешно создана!\n\n"
success_text += f"📋 Подписка: {subscription.name}\n"
success_text += f"⏰ Действует до: {format_date(expires_at, user.language)}\n"
success_text += f"💰 Стоимость: {subscription.price} руб.\n\n"
# Получаем subscription URL из API
try:
subscription_url = await api.get_subscription_url(short_uuid)
if subscription_url:
success_text += f"🔗 <a href='{subscription_url}'>Нажмите для подключения</a>\n\n"
success_text += "📱 Скопируйте ссылку и импортируйте конфигурацию в ваше VPN приложение"
else:
success_text += "⚠️ Ссылка для подключения будет доступна в разделе 'Мои подписки'"
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
success_text += "⚠️ Ссылка для подключения будет доступна в разделе 'Мои подписки'"
await callback.message.edit_text(
t('subscription_purchased', user.language),
reply_markup=main_menu_keyboard(user.language, user.is_admin)
success_text,
reply_markup=main_menu_keyboard(user.language, user.is_admin),
parse_mode='HTML',
disable_web_page_preview=True
)
log_user_action(user.telegram_id, "subscription_purchased", f"Sub: {subscription.name}")
@@ -743,10 +776,13 @@ async def confirm_purchase(callback: CallbackQuery, db: Database, **kwargs):
reply_markup=main_menu_keyboard(user.language, user.is_admin)
)
# My subscriptions
@router.callback_query(F.data == "my_subscriptions")
async def my_subscriptions_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Show user's subscriptions"""
"""Show user's subscriptions with URLs from API"""
user = kwargs.get('user')
api = kwargs.get('api')
if not user:
await callback.answer("❌ Ошибка пользователя")
return
@@ -761,7 +797,42 @@ async def my_subscriptions_callback(callback: CallbackQuery, db: Database, **kwa
)
return
# Get subscription details
text = t('your_subscriptions', user.language) + "\n\n"
for i, user_sub in enumerate(user_subs, 1):
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
if not subscription:
continue
# Определяем статус
now = datetime.utcnow()
if user_sub.expires_at < now:
status = "❌ Истекла"
elif not user_sub.is_active:
status = "⏸ Неактивна"
else:
days_left = (user_sub.expires_at - now).days
status = f"✅ Активна ({days_left} дн.)"
text += f"{i}. {subscription.name}\n"
text += f" {status}\n"
text += f" До: {format_date(user_sub.expires_at, user.language)}\n"
# НОВОЕ: Получаем URL из API
if user_sub.short_uuid and api:
try:
subscription_url = await api.get_subscription_url(user_sub.short_uuid)
if subscription_url:
text += f" 🔗 <a href='{subscription_url}'>Подключить</a>\n"
else:
text += f" 🔗 URL недоступен\n"
except Exception as e:
logger.warning(f"Could not get subscription URL for {user_sub.short_uuid}: {e}")
text += f" 🔗 URL недоступен\n"
text += "\n"
# Convert to old format for keyboard
sub_list = []
for user_sub in user_subs:
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
@@ -772,17 +843,22 @@ async def my_subscriptions_callback(callback: CallbackQuery, db: Database, **kwa
})
await callback.message.edit_text(
t('my_subscriptions', user.language),
reply_markup=user_subscriptions_keyboard(sub_list, user.language)
text,
reply_markup=user_subscriptions_keyboard(sub_list, user.language),
parse_mode='HTML',
disable_web_page_preview=True
)
except Exception as e:
logger.error(f"Error getting user subscriptions: {e}")
await callback.answer(t('error_occurred', user.language))
@router.callback_query(F.data.startswith("view_sub_"))
async def view_subscription_detail(callback: CallbackQuery, db: Database, **kwargs):
"""View subscription details"""
"""View subscription details with URL from API"""
user = kwargs.get('user')
api = kwargs.get('api')
if not user:
await callback.answer("❌ Ошибка пользователя")
return
@@ -810,29 +886,38 @@ async def view_subscription_detail(callback: CallbackQuery, db: Database, **kwar
'description': subscription.description or ''
}
# Check if subscription is expiring soon (within 3 days)
from datetime import datetime, timedelta
# Check if subscription is expiring soon
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
# ИСПРАВЛЕНИЕ: Запрещаем продление тестовых подписок
show_extend = (0 <= days_until_expiry <= 3 and
user_sub.is_active and
not subscription.is_trial) # Добавили проверку на тестовую подписку
not subscription.is_trial)
text = format_user_subscription_info(user_sub.__dict__, sub_dict, user_sub.expires_at, user.language)
# Add expiry warning if subscription expires soon (but don't show for trial)
# НОВОЕ: Добавляем URL из API в детальный просмотр
if user_sub.short_uuid and api:
try:
subscription_url = await api.get_subscription_url(user_sub.short_uuid)
if subscription_url:
text += f"\n\n🔗 <a href='{subscription_url}'>Ссылка для подключения</a>"
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
# Add expiry warning if subscription expires soon
if show_extend:
text += f"\n\n⚠️ {t('subscription_expires_soon', user.language, days=days_until_expiry)}"
elif subscription.is_trial and 0 <= days_until_expiry <= 3:
# Специальное сообщение для тестовых подписок
text += f"\n\n ℹ️ Тестовая подписка истекает через {days_until_expiry} дн. Продление недоступно."
text += f"\n\nℹ️ Тестовая подписка истекает через {days_until_expiry} дн. Продление недоступно."
await callback.message.edit_text(
text,
reply_markup=user_subscription_detail_keyboard(user_sub_id, user.language, show_extend)
reply_markup=user_subscription_detail_keyboard(user_sub_id, user.language, show_extend),
parse_mode='HTML',
disable_web_page_preview=True
)
except Exception as e:
logger.error(f"Error viewing subscription detail: {e}")
await callback.answer(t('error_occurred', user.language))
@@ -849,7 +934,6 @@ async def extend_subscription_callback(callback: CallbackQuery, db: Database, **
try:
user_sub_id = int(callback.data.split("_")[2])
# Получаем все подписки пользователя и находим нужную
user_subs = await db.get_user_subscriptions(user.telegram_id)
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
@@ -857,18 +941,15 @@ async def extend_subscription_callback(callback: CallbackQuery, db: Database, **
await callback.answer(t('subscription_not_found', user.language))
return
# Get subscription details
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
if not subscription:
await callback.answer(t('subscription_not_found', user.language))
return
# Check if subscription is trial (can't extend trial)
if subscription.is_trial:
await callback.answer("❌ Тестовую подписку нельзя продлить")
return
# Check if user has enough balance
if user.balance < subscription.price:
needed = subscription.price - user.balance
text = f"❌ Недостаточно средств для продления!\n\n"
@@ -904,7 +985,7 @@ async def extend_subscription_callback(callback: CallbackQuery, db: Database, **
@router.callback_query(F.data.startswith("confirm_extend_"))
async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Confirm subscription extension"""
"""Confirm subscription extension - ДОБАВЛЕНА ПОДДЕРЖКА URL ИЗ API"""
user = kwargs.get('user')
api = kwargs.get('api')
@@ -915,7 +996,6 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
try:
user_sub_id = int(callback.data.split("_")[2])
# Получаем все подписки пользователя и находим нужную
user_subs = await db.get_user_subscriptions(user.telegram_id)
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
@@ -923,75 +1003,57 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
await callback.answer(t('subscription_not_found', user.language))
return
# Get subscription details
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
if not subscription:
await callback.answer(t('subscription_not_found', user.language))
return
# Check if subscription is trial (can't extend trial)
if subscription.is_trial:
await callback.answer("❌ Тестовую подписку нельзя продлить")
return
# Check balance again
if user.balance < subscription.price:
await callback.answer("❌ Недостаточно средств")
return
# Calculate new expiry date
from datetime import datetime, timedelta
now = datetime.utcnow()
# ИСПРАВЛЕНИЕ: Правильное вычисление новой даты истечения
# Если подписка все еще активна, продлеваем от текущей даты истечения
# Если истекла, продлеваем от текущего момента
if user_sub.expires_at > now:
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
else:
new_expiry = now + timedelta(days=subscription.duration_days)
# ГЛАВНОЕ ИСПРАВЛЕНИЕ: Обновляем подписку в RemnaWave с правильными полями
# Update in RemnaWave
if api and user_sub.short_uuid:
try:
logger.info(f"Updating RemnaWave subscription for shortUuid: {user_sub.short_uuid}")
# Сначала получаем информацию о пользователе по short_uuid
remna_user_details = await api.get_user_by_short_uuid(user_sub.short_uuid)
if remna_user_details:
user_uuid = remna_user_details.get('uuid')
if user_uuid:
# ИСПРАВЛЕНИЕ: Используем правильное поле для даты истечения
# В RemnaWave API может использоваться 'expireAt' или 'expiryTime'
expiry_str = new_expiry.isoformat() + 'Z'
# Попробуем оба варианта поля даты истечения
update_data_v1 = {
update_data = {
'enable': True,
'expireAt': expiry_str # Вариант 1
}
update_data_v2 = {
'enable': True,
'expiryTime': expiry_str # Вариант 2
'expireAt': expiry_str
}
logger.info(f"Updating user {user_uuid} with new expiry: {expiry_str}")
# Пробуем первый вариант
result = await api.update_user(user_uuid, update_data_v1)
result = await api.update_user(user_uuid, update_data)
if not result:
# Если первый не сработал, пробуем второй
logger.info("Trying alternative field name 'expiryTime'")
result = await api.update_user(user_uuid, update_data_v2)
# Try alternative field name
update_data['expiryTime'] = expiry_str
result = await api.update_user(user_uuid, update_data)
if result:
logger.info(f"Successfully updated RemnaWave user expiry to {expiry_str}")
logger.info(f"Successfully updated RemnaWave user expiry")
else:
logger.warning(f"Failed to update user in RemnaWave - trying direct API call")
logger.warning(f"Failed to update user in RemnaWave")
# ДОПОЛНИТЕЛЬНАЯ ПОПЫТКА: Используем специальный метод для обновления даты истечения
if hasattr(api, 'update_user_expiry'):
result = await api.update_user_expiry(user_sub.short_uuid, expiry_str)
if result:
@@ -1003,7 +1065,6 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
except Exception as e:
logger.error(f"Failed to update expiry in RemnaWave: {e}")
# НЕ прерываем выполнение, продолжаем обновление в локальной БД
# Update local database
user_sub.expires_at = new_expiry
@@ -1029,12 +1090,24 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
success_text += f"💰 Списано: {subscription.price} руб.\n"
success_text += f"💳 Остаток на балансе: {user.balance} руб."
# НОВОЕ: Получаем обновленный URL из API
if api and user_sub.short_uuid:
try:
subscription_url = await api.get_subscription_url(user_sub.short_uuid)
if subscription_url:
success_text += f"\n\n🔗 <a href='{subscription_url}'>Обновленная ссылка для подключения</a>"
success_text += f"\n📱 Можете использовать прежнюю конфигурацию или обновить по ссылке"
except Exception as e:
logger.warning(f"Could not get updated subscription URL: {e}")
await callback.message.edit_text(
success_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
])
]),
parse_mode='HTML',
disable_web_page_preview=True
)
log_user_action(user.telegram_id, "subscription_extended", f"Sub: {subscription.name}")
@@ -1046,13 +1119,11 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
reply_markup=main_menu_keyboard(user.language, user.is_admin)
)
@router.callback_query(F.data.startswith("get_connection_"))
async def get_connection_callback(callback: CallbackQuery, db: Database, **kwargs):
"""Get connection link"""
"""Get connection link from API - ПОЛНОСТЬЮ ПЕРЕРАБОТАН"""
user = kwargs.get('user')
api = kwargs.get('api')
config = kwargs.get('config')
if not user:
await callback.answer("❌ Ошибка пользователя")
@@ -1067,73 +1138,52 @@ async def get_connection_callback(callback: CallbackQuery, db: Database, **kwarg
await callback.answer("❌ Подписка не найдена")
return
# Используем shortUuid напрямую
short_uuid = user_sub.short_uuid
if not user_sub.short_uuid:
await callback.answer("❌ Данные подписки недоступны")
return
# ИСПРАВЛЕНИЕ: Приоритет config.SUBSCRIPTION_BASE_URL
if config and config.SUBSCRIPTION_BASE_URL:
connection_url = f"{config.SUBSCRIPTION_BASE_URL.rstrip('/')}/sub/{short_uuid}"
logger.info(f"Using config SUBSCRIPTION_BASE_URL: {connection_url}")
elif api:
# Если конфиг недоступен, используем API
connection_url = await api.get_subscription_url(short_uuid)
logger.info(f"Using API subscription URL: {connection_url}")
else:
# Последний fallback
connection_url = f"{config.SUBSCRIPTION_BASE_URL.rstrip('/')}/sub/{short_uuid}"
logger.warning(f"Using hardcoded fallback URL: {connection_url}")
connection_url = None
if api:
try:
connection_url = await api.get_subscription_url(user_sub.short_uuid)
logger.info(f"Got subscription URL from API: {connection_url}")
except Exception as e:
logger.error(f"Failed to get URL from API: {e}")
text = t('connection_link', user.language, link=connection_url)
if not connection_url:
await callback.message.edit_text(
"❌ Не удалось получить ссылку для подключения\n\nПопробуйте позже или обратитесь в поддержку",
reply_markup=back_keyboard("my_subscriptions", user.language)
)
return
text = f"🔗 Ссылка для подключения готова!\n\n"
text += f"📋 Подписка: {user_sub.id}\n"
text += f"🔗 Ссылка: <code>{connection_url}</code>\n\n"
text += f"📱 Инструкция:\n"
text += f"1. Скопируйте ссылку выше\n"
text += f"2. Откройте ваше VPN приложение\n"
text += f"3. Добавьте конфигурацию по ссылке\n\n"
text += f"💡 Или нажмите кнопку ниже для автоматического подключения"
# Создаем клавиатуру с кнопкой подключения
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", web_app=WebAppInfo(url=connection_url))],
[InlineKeyboardButton(text=t('back', user.language), callback_data="my_subscriptions")]
[InlineKeyboardButton(text="🚀 Подключиться автоматически", url=connection_url)],
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
])
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode='Markdown'
parse_mode='HTML'
)
except Exception as e:
logger.error(f"Error getting connection link: {e}")
await callback.answer(t('error_occurred', user.language))
@router.callback_query(F.data.startswith("connect_sub_"))
async def connect_subscription_callback(callback: CallbackQuery, db: Database, api: RemnaWaveAPI, **kwargs):
"""Handle subscription connection button"""
user = kwargs.get('user')
config = kwargs.get('config')
if not user:
await callback.answer("❌ Ошибка пользователя")
return
try:
user_subs = await db.get_user_subscriptions(user.telegram_id)
sub_id = int(callback.data.split("_")[2])
user_sub = next((s for s in user_subs if s.id == sub_id), None)
if not user_sub:
await callback.answer("❌ Подписка не найдена")
return
# Получаем ссылку из RemnaWave API
connection_url = await api.get_subscription_by_short_uuid(user_sub.short_uuid)
if not connection_url:
# Используем config.SUBSCRIPTION_BASE_URL
subscription_base_url = config.SUBSCRIPTION_BASE_URL if config else "http://localhost"
connection_url = f"{subscription_base_url.rstrip('/')}/sub/{user_sub.short_uuid}"
# Открываем ссылку через URL кнопку
await callback.answer(url=connection_url)
except Exception as e:
logger.error(f"Error connecting to subscription: {e}")
await callback.answer(t('error_occurred', user.language))
# Support
# Support и Promocode handlers
@router.callback_query(F.data == "support")
async def support_callback(callback: CallbackQuery, **kwargs):
"""Show support info"""
@@ -1152,7 +1202,6 @@ async def support_callback(callback: CallbackQuery, **kwargs):
reply_markup=back_keyboard("main_menu", user.language)
)
# Promocode
@router.callback_query(F.data == "promocode")
async def promocode_callback(callback: CallbackQuery, state: FSMContext, **kwargs):
"""Handle promocode input"""
+258 -122
View File
@@ -11,7 +11,7 @@ class RemnaWaveAPI:
def __init__(self, base_url: str, token: str, subscription_base_url: str = None):
self.base_url = base_url.rstrip('/')
self.token = token
self.subscription_base_url = subscription_base_url or base_url
self.subscription_base_url = subscription_base_url
self.session = None
async def _get_session(self):
@@ -19,7 +19,7 @@ class RemnaWaveAPI:
headers = {
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json',
'Accept': 'application/json' # Явно запрашиваем JSON
'Accept': 'application/json'
}
timeout = aiohttp.ClientTimeout(total=30)
self.session = aiohttp.ClientSession(
@@ -77,7 +77,138 @@ class RemnaWaveAPI:
logger.error(f"Request error for {endpoint}: {e}")
return None
# User management
async def get_subscription_info(self, short_uuid: str) -> Optional[Dict]:
"""Get subscription info including URL from RemnaWave API"""
try:
logger.info(f"Getting subscription info for short_uuid: {short_uuid}")
# Пробуем разные эндпоинты для получения информации о подписке
endpoints_to_try = [
f'/api/subscriptions/{short_uuid}',
f'/api/sub/{short_uuid}',
f'/api/subscription/{short_uuid}'
]
for endpoint in endpoints_to_try:
logger.debug(f"Trying endpoint: {endpoint}")
result = await self._make_request('GET', endpoint)
if result:
logger.info(f"Successfully got subscription info from {endpoint}")
# Обрабатываем разные структуры ответа
subscription_data = None
if 'response' in result:
subscription_data = result['response']
elif 'data' in result:
subscription_data = result['data']
elif 'subscription' in result:
subscription_data = result['subscription']
else:
subscription_data = result
# Проверяем что получили нужные данные
if subscription_data and (
'subscriptionUrl' in subscription_data or
'url' in subscription_data or
'link' in subscription_data
):
return subscription_data
logger.warning(f"Could not get subscription info for {short_uuid} from any endpoint")
return None
except Exception as e:
logger.error(f"Error getting subscription info for {short_uuid}: {e}")
return None
async def get_subscription_url(self, short_uuid: str) -> str:
"""Get subscription URL from RemnaWave API - ОБНОВЛЕННЫЙ МЕТОД"""
try:
logger.info(f"Getting subscription URL for short_uuid: {short_uuid}")
# Сначала пробуем получить URL из API
subscription_info = await self.get_subscription_info(short_uuid)
if subscription_info:
# Ищем URL в разных возможных полях
subscription_url = (
subscription_info.get('subscriptionUrl') or
subscription_info.get('url') or
subscription_info.get('link') or
subscription_info.get('subscription_url')
)
if subscription_url:
logger.info(f"Got subscription URL from API: {subscription_url}")
return subscription_url
# Если не получилось через API, пробуем через пользователя
user_data = await self.get_user_by_short_uuid(short_uuid)
if user_data and 'subscriptionUrl' in user_data:
logger.info(f"Got subscription URL from user data: {user_data['subscriptionUrl']}")
return user_data['subscriptionUrl']
# Последний fallback - формируем URL самостоятельно
if self.subscription_base_url:
fallback_url = f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
logger.warning(f"Using fallback URL: {fallback_url}")
return fallback_url
else:
# Если нет даже fallback URL, пытаемся угадать из base_url
fallback_url = f"{self.base_url.rstrip('/')}/sub/{short_uuid}"
logger.warning(f"Using base_url fallback: {fallback_url}")
return fallback_url
except Exception as e:
logger.error(f"Failed to get subscription URL for {short_uuid}: {e}")
# Последний resort
fallback_url = f"{self.base_url.rstrip('/')}/sub/{short_uuid}"
return fallback_url
async def get_all_subscriptions_with_urls(self) -> Optional[List]:
"""Get all subscriptions with their URLs from RemnaWave API"""
try:
logger.info("Fetching all subscriptions with URLs from API")
result = await self._make_request('GET', '/api/subscriptions')
if not result:
logger.error("Empty response from subscriptions API")
return []
subscriptions_list = []
# Extract subscriptions from response
if 'response' in result and 'subscriptions' in result['response']:
subscriptions_list = result['response']['subscriptions']
elif 'subscriptions' in result:
subscriptions_list = result['subscriptions']
elif 'data' in result:
subscriptions_list = result['data']
elif isinstance(result, list):
subscriptions_list = result
# Process subscriptions to ensure they have URLs
processed_subscriptions = []
for subscription in subscriptions_list:
if subscription.get('isFound') and 'user' in subscription:
user_data = subscription['user']
# Добавляем URL если его нет
if 'subscriptionUrl' not in subscription and user_data.get('shortUuid'):
subscription['subscriptionUrl'] = await self.get_subscription_url(user_data['shortUuid'])
processed_subscriptions.append(subscription)
logger.info(f"Processed {len(processed_subscriptions)} subscriptions with URLs")
return processed_subscriptions
except Exception as e:
logger.error(f"Exception in get_all_subscriptions_with_urls: {e}", exc_info=True)
return []
# User management - без изменений
async def create_user(self, username: str, password: str = None,
traffic_limit: int = 0, expiry_time: str = None,
telegram_id: int = None, email: str = None,
@@ -143,7 +274,6 @@ class RemnaWaveAPI:
elif 'user' in result:
user_data = result['user']
else:
# Если нет стандартных полей, возможно сам result - это пользователь
if 'telegramId' in result or 'username' in result:
user_data = result
elif isinstance(result, list):
@@ -155,6 +285,16 @@ class RemnaWaveAPI:
# Проверяем что это действительно нужный пользователь
if user_data.get('telegramId') == telegram_id:
logger.info(f"Found user: {user_data.get('username')} for Telegram ID {telegram_id}")
# НОВОЕ: Обогащаем данные пользователя URL подписки если есть short_uuid
if user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
logger.debug(f"Added subscription URL to user data: {subscription_url}")
except Exception as e:
logger.warning(f"Could not get subscription URL for user: {e}")
return user_data
else:
logger.warning(f"Telegram ID mismatch: expected {telegram_id}, got {user_data.get('telegramId')}")
@@ -167,11 +307,23 @@ class RemnaWaveAPI:
result = await self._make_request('GET', f'/api/users/{uuid}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL for user: {e}")
return user_data
return None
async def get_user_by_short_uuid(self, short_uuid: str) -> Optional[Dict]:
@@ -180,11 +332,23 @@ class RemnaWaveAPI:
result = await self._make_request('GET', f'/api/users/by-short-uuid/{short_uuid}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(short_uuid)
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def update_user(self, uuid: str, data: Dict) -> Optional[Dict]:
@@ -246,18 +410,8 @@ class RemnaWaveAPI:
'trafficLimitBytes': traffic_bytes
}
return await self.update_user(uuid, update_data)
async def get_subscription_url(self, short_uuid: str) -> str:
"""Get subscription URL for a given short UUID"""
try:
subscription_url = f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
logger.info(f"Generated subscription URL: {subscription_url}")
return subscription_url
except Exception as e:
logger.error(f"Failed to get subscription URL: {e}")
return f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
# Nodes management - FIXED with proper endpoints
# Nodes management
async def get_all_nodes(self) -> Optional[List]:
"""Get all nodes with proper field mapping"""
try:
@@ -381,7 +535,7 @@ class RemnaWaveAPI:
"""Disable specific node"""
return await self._make_request('POST', f'/api/nodes/{node_id}/actions/disable')
# System stats
# System stats - без изменений
async def get_system_stats(self) -> Optional[Dict]:
"""Get system statistics with proper parsing"""
try:
@@ -432,13 +586,13 @@ class RemnaWaveAPI:
return None
async def get_all_system_users_full(self) -> Optional[List]:
"""Get all system users without pagination - FIXED"""
"""Get all system users with subscription URLs - ОБНОВЛЕННЫЙ МЕТОД"""
try:
all_users = []
offset = 0
limit = 100
logger.info("Starting to fetch all system users")
logger.info("Starting to fetch all system users with URLs")
while True:
logger.debug(f"Fetching users batch: offset={offset}, limit={limit}")
@@ -484,12 +638,23 @@ class RemnaWaveAPI:
if not batch_users:
logger.info(f"No users in batch at offset {offset}, stopping")
break
# Логируем первого пользователя для отладки структуры
if batch_users and offset == 0:
logger.debug(f"First user structure: {list(batch_users[0].keys()) if batch_users[0] else 'empty'}")
all_users.extend(batch_users)
# НОВОЕ: Обогащаем каждого пользователя subscription URL
enriched_users = []
for user in batch_users:
try:
# Добавляем subscription URL если есть shortUuid и его еще нет
if user.get('shortUuid') and 'subscriptionUrl' not in user:
subscription_url = await self.get_subscription_url(user['shortUuid'])
user['subscriptionUrl'] = subscription_url
logger.debug(f"Added subscription URL for user {user.get('username', 'unknown')}")
enriched_users.append(user)
except Exception as e:
logger.warning(f"Could not enrich user {user.get('username', 'unknown')} with URL: {e}")
enriched_users.append(user) # Добавляем пользователя даже без URL
all_users.extend(enriched_users)
# Проверяем условия остановки
if len(batch_users) < limit:
@@ -511,7 +676,8 @@ class RemnaWaveAPI:
# Финальная статистика
if all_users:
active_users = len([u for u in all_users if str(u.get('status', '')).upper() == 'ACTIVE'])
logger.info(f"Successfully fetched {len(all_users)} users (Active: {active_users})")
users_with_urls = len([u for u in all_users if u.get('subscriptionUrl')])
logger.info(f"Successfully fetched {len(all_users)} users (Active: {active_users}, With URLs: {users_with_urls})")
else:
logger.warning("No users found in system")
@@ -521,6 +687,9 @@ class RemnaWaveAPI:
logger.error(f"Error getting all system users: {e}", exc_info=True)
return []
# Остальные методы остаются без изменений...
# (Internal squads, user search, user actions, bulk operations, debug methods, health check, etc.)
# Internal squads
async def get_internal_squads_list(self) -> Optional[List[Dict]]:
"""Get list of internal squads"""
@@ -561,7 +730,6 @@ class RemnaWaveAPI:
return 0
async def debug_users_api(self) -> Dict:
"""Debug method to check users API response structure"""
try:
logger.info("=== DEBUGGING USERS API ===")
@@ -631,33 +799,69 @@ class RemnaWaveAPI:
"""Get user by username"""
result = await self._make_request('GET', f'/api/users/by-username/{username}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def get_user_by_email(self, email: str) -> Optional[Dict]:
"""Get user by email"""
result = await self._make_request('GET', f'/api/users/by-email/{email}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def get_user_by_tag(self, tag: str) -> Optional[Dict]:
"""Get user by tag"""
result = await self._make_request('GET', f'/api/users/by-tag/{tag}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
# User actions
@@ -821,8 +1025,9 @@ class RemnaWaveAPI:
logger.error(f"Error getting nodes statistics: {e}")
return {'data': []}
# ОБНОВЛЯЕМ метод get_all_subscriptions чтобы правильно обрабатывать URLs
async def get_all_subscriptions(self) -> Optional[List]:
"""Get all subscriptions from RemnaWave"""
"""Get all subscriptions from RemnaWave - ОБНОВЛЕННАЯ ВЕРСИЯ"""
try:
logger.info("Fetching all subscriptions from API")
result = await self._make_request('GET', '/api/subscriptions')
@@ -859,9 +1064,19 @@ class RemnaWaveAPI:
'trafficUsed': user_data.get('trafficUsed', '0'),
'trafficLimit': user_data.get('trafficLimit', '0'),
'daysLeft': user_data.get('daysLeft', 0),
'subscriptionUrl': subscription.get('subscriptionUrl'),
# ВАЖНО: Используем subscriptionUrl из самой подписки, а не генерируем
'subscriptionUrl': subscription.get('subscriptionUrl') or subscription.get('url'),
'links': subscription.get('links', [])
}
# Если нет URL в подписке, пытаемся получить его
if not processed_user.get('subscriptionUrl') and processed_user.get('shortUuid'):
try:
subscription_url = await self.get_subscription_url(processed_user['shortUuid'])
processed_user['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL for {processed_user.get('username')}: {e}")
processed_users.append(processed_user)
logger.info(f"Processed {len(processed_users)} active subscriptions")
@@ -871,85 +1086,6 @@ class RemnaWaveAPI:
logger.error(f"Exception in get_all_subscriptions: {e}", exc_info=True)
return []
async def get_all_system_users_full(self) -> Optional[List]:
"""Get all system users - updated to use subscriptions API"""
try:
# Сначала пробуем получить через API пользователей
logger.info("Trying to get users via /api/users")
users_result = await self._get_users_paginated()
if users_result:
logger.info(f"Got {len(users_result)} users from users API")
return users_result
# Если не получилось, пробуем через подписки
logger.info("Fallback to subscriptions API")
subscriptions_users = await self.get_all_subscriptions()
if subscriptions_users:
logger.info(f"Got {len(subscriptions_users)} users from subscriptions API")
return subscriptions_users
logger.warning("No users found via any API endpoint")
return []
except Exception as e:
logger.error(f"Error getting all system users: {e}", exc_info=True)
return []
async def _get_users_paginated(self) -> Optional[List]:
"""Get users via paginated API calls"""
try:
all_users = []
offset = 0
limit = 100
while True:
logger.debug(f"Fetching users batch: offset={offset}, limit={limit}")
result = await self._make_request('GET', '/api/users',
params={'offset': offset, 'limit': limit})
if not result:
break
batch_users = []
# Extract users from various response structures
if isinstance(result, dict):
if 'users' in result:
batch_users = result['users'] if isinstance(result['users'], list) else []
elif 'data' in result:
batch_users = result['data'] if isinstance(result['data'], list) else []
elif 'response' in result:
if isinstance(result['response'], dict):
if 'users' in result['response']:
batch_users = result['response']['users'] if isinstance(result['response']['users'], list) else []
elif 'data' in result['response']:
batch_users = result['response']['data'] if isinstance(result['response']['data'], list) else []
elif isinstance(result['response'], list):
batch_users = result['response']
elif isinstance(result, list):
batch_users = result
if not batch_users:
break
all_users.extend(batch_users)
if len(batch_users) < limit:
break
offset += limit
if offset > 10000: # Safety limit
break
return all_users if all_users else None
except Exception as e:
logger.error(f"Error in _get_users_paginated: {e}")
return None
async def bulk_reset_all_traffic(self) -> Optional[Dict]:
"""Reset traffic for all users using the correct API endpoint"""
try:
+1
View File
@@ -64,6 +64,7 @@ TRANSLATIONS = {
'subscription_expires': 'Истекает: {date}',
'subscription_expired': 'Истекла',
'subscription_active': 'Активна до: {date}',
'your_subscriptions': '👀 Список ваших подписок:',
# Payments
'payment_created': '✅ Заявка на пополнение создана.\nОбратитесь к @{support} для оплаты.',
+41 -3
View File
@@ -205,9 +205,10 @@ def format_bytes(bytes_value: int) -> str:
else:
return f"{value:.1f} {units[unit_index]}"
def get_subscription_connection_url(base_url: str, short_uuid: str) -> str:
"""Generate subscription connection URL"""
return f"{base_url.rstrip('/')}/api/sub/{short_uuid}"
# УДАЛЯЕМ функцию get_subscription_connection_url - теперь URL берется из API
# def get_subscription_connection_url(base_url: str, short_uuid: str) -> str:
# """Generate subscription connection URL"""
# return f"{base_url.rstrip('/')}/api/sub/{short_uuid}"
def log_user_action(telegram_id: int, action: str, details: str = None):
"""Log user action for audit"""
@@ -260,6 +261,43 @@ def is_subscription_expiring_soon(expires_at: datetime, warning_days: int = 2) -
days_left = calculate_days_until_expiry(expires_at)
return days_left <= warning_days
# НОВАЯ ФУНКЦИЯ: Извлечение subscription URL из данных пользователя
def extract_subscription_url(user_data: Dict[str, Any]) -> Optional[str]:
"""Extract subscription URL from user data with fallback logic"""
if not user_data:
return None
# Пробуем разные возможные поля для URL
url_fields = [
'subscriptionUrl',
'subscription_url',
'url',
'link',
'connectionUrl',
'connection_url'
]
for field in url_fields:
url = user_data.get(field)
if url and isinstance(url, str) and url.strip():
return url.strip()
return None
def format_subscription_url_display(subscription_url: str, lang: str = 'ru') -> str:
"""Format subscription URL for display in messages"""
if not subscription_url:
return "❌ URL недоступен" if lang == 'ru' else "❌ URL unavailable"
# Показываем только домен для безопасности
try:
from urllib.parse import urlparse
parsed = urlparse(subscription_url)
domain = parsed.netloc or parsed.path.split('/')[0]
return f"🔗 {domain}"
except Exception:
return "🔗 Ссылка готова" if lang == 'ru' else "🔗 Link ready"
class States:
"""State constants for FSM"""
WAITING_LANGUAGE = "waiting_language"