diff --git a/telegram_bot/admin.py b/telegram_bot/admin.py index 9ee523a..aa8346c 100644 --- a/telegram_bot/admin.py +++ b/telegram_bot/admin.py @@ -8,11 +8,37 @@ from django import forms from django.contrib.admin.widgets import FilteredSelectMultiple from .models import BotSettings, TelegramMessage, AccessRequest from .localization import MessageLocalizer +from vpn.models import User import logging logger = logging.getLogger(__name__) +class BotSettingsAdminForm(forms.ModelForm): + """Custom form for BotSettings with Telegram admin selection""" + + class Meta: + model = BotSettings + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Show all users for telegram_admins selection + if 'telegram_admins' in self.fields: + self.fields['telegram_admins'].queryset = User.objects.all().order_by('username') + self.fields['telegram_admins'].help_text = ( + "Select users who will have admin access in the bot. " + "Users will get admin rights when they connect to the bot with their Telegram account." + ) + + def clean_telegram_admins(self): + """Validate that selected admins have telegram_user_id or telegram_username""" + admins = self.cleaned_data.get('telegram_admins') + # No validation needed - admins can be selected even without telegram connection + # They will get admin rights when they connect via bot + return admins + + class AccessRequestAdminForm(forms.ModelForm): """Custom form for AccessRequest with existing user selection""" @@ -20,10 +46,6 @@ class AccessRequestAdminForm(forms.ModelForm): model = AccessRequest fields = '__all__' widgets = { - 'selected_inbounds': FilteredSelectMultiple( - verbose_name='Inbound Templates', - is_stacked=False - ), 'selected_subscription_groups': FilteredSelectMultiple( verbose_name='Subscription Groups', is_stacked=False @@ -43,13 +65,7 @@ class AccessRequestAdminForm(forms.ModelForm): telegram_user_id__isnull=True ).order_by('username') - # Configure inbound and subscription group fields - if 'selected_inbounds' in self.fields: - from vpn.models_xray import Inbound - self.fields['selected_inbounds'].queryset = Inbound.objects.all().order_by('name') - self.fields['selected_inbounds'].label = 'Inbound Templates' - self.fields['selected_inbounds'].help_text = 'Select inbound templates to assign to this user' - + # Configure subscription group fields if 'selected_subscription_groups' in self.fields: from vpn.models_xray import SubscriptionGroup self.fields['selected_subscription_groups'].queryset = SubscriptionGroup.objects.filter( @@ -61,12 +77,17 @@ class AccessRequestAdminForm(forms.ModelForm): @admin.register(BotSettings) class BotSettingsAdmin(admin.ModelAdmin): - list_display = ('__str__', 'enabled', 'bot_token_display', 'updated_at') + form = BotSettingsAdminForm + list_display = ('__str__', 'enabled', 'bot_token_display', 'admin_count_display', 'updated_at') fieldsets = ( ('Bot Configuration', { 'fields': ('bot_token', 'enabled', 'bot_status_display'), 'description': 'Configure bot settings and view current status' }), + ('Admin Management', { + 'fields': ('telegram_admins', 'admin_info_display'), + 'description': 'Select users with linked Telegram accounts who will have admin access in the bot' + }), ('Connection Settings', { 'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'), 'classes': ('collapse',) @@ -76,7 +97,8 @@ class BotSettingsAdmin(admin.ModelAdmin): 'classes': ('collapse',) }), ) - readonly_fields = ('created_at', 'updated_at', 'bot_status_display') + readonly_fields = ('created_at', 'updated_at', 'bot_status_display', 'admin_info_display') + filter_horizontal = ('telegram_admins',) def bot_token_display(self, obj): """Mask bot token for security""" @@ -88,6 +110,60 @@ class BotSettingsAdmin(admin.ModelAdmin): return "No token set" bot_token_display.short_description = "Bot Token" + def admin_count_display(self, obj): + """Display count of Telegram admins""" + count = obj.telegram_admins.count() + if count == 0: + return "No admins" + elif count == 1: + return "1 admin" + else: + return f"{count} admins" + admin_count_display.short_description = "Telegram Admins" + + def admin_info_display(self, obj): + """Display detailed admin information""" + if not obj.pk: + return "Save settings first to manage admins" + + admins = obj.telegram_admins.all() + + if not admins.exists(): + html = '
' + html += '

⚠️ No Telegram admins configured

' + html += '

Select users above to give them admin access in the Telegram bot.

' + html += '
' + else: + html = '
' + html += f'

✅ {admins.count()} Telegram admin(s) configured

' + html += '
' + + for admin in admins: + html += '
' + html += f'{admin.username}' + + if admin.telegram_username: + html += f' (@{admin.telegram_username})' + + html += f' ID: {admin.telegram_user_id}' + + if admin.first_name or admin.last_name: + name_parts = [] + if admin.first_name: + name_parts.append(admin.first_name) + if admin.last_name: + name_parts.append(admin.last_name) + html += f'
Name: {" ".join(name_parts)}' + + html += '
' + + html += '
' + html += '

These users will receive notifications about new access requests and can approve/reject them directly in Telegram.

' + html += '
' + + return format_html(html) + admin_info_display.short_description = "Admin Configuration" + def bot_status_display(self, obj): """Display bot status with control buttons""" from .bot import TelegramBotManager @@ -365,10 +441,9 @@ class AccessRequestAdmin(admin.ModelAdmin): }), ('VPN Access Configuration', { 'fields': ( - 'selected_inbounds', 'selected_subscription_groups', ), - 'description': 'Select inbound templates and subscription groups to assign to the user' + 'description': 'Select subscription groups to assign to the user' }), ('Telegram User', { 'fields': ( @@ -620,11 +695,12 @@ class AccessRequestAdmin(admin.ModelAdmin): raise def _assign_vpn_access(self, user, access_request): - """Assign selected inbounds and subscription groups to the user""" + """Assign selected subscription groups to the user""" try: - from vpn.models_xray import UserSubscription, SubscriptionGroup + from vpn.models_xray import UserSubscription # Assign subscription groups + group_count = 0 for subscription_group in access_request.selected_subscription_groups.all(): user_subscription, created = UserSubscription.objects.get_or_create( user=user, @@ -633,48 +709,16 @@ class AccessRequestAdmin(admin.ModelAdmin): ) if created: logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}") + group_count += 1 else: # Ensure it's active if it already existed if not user_subscription.active: user_subscription.active = True user_subscription.save() logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}") + group_count += 1 - # Handle individual inbounds - create a custom subscription group for them - selected_inbounds = access_request.selected_inbounds.all() - if selected_inbounds.exists(): - # Create a custom subscription group for this user's individual inbounds - custom_group_name = f"Custom_{user.username}_{access_request.id}" - custom_group, created = SubscriptionGroup.objects.get_or_create( - name=custom_group_name, - defaults={ - 'description': f'Custom inbounds for {user.username} from Telegram request', - 'is_active': True - } - ) - - if created: - # Add selected inbounds to the custom group - custom_group.inbounds.set(selected_inbounds) - logger.info(f"Created custom subscription group '{custom_group_name}' with {selected_inbounds.count()} inbounds") - else: - # Update existing custom group - custom_group.inbounds.add(*selected_inbounds) - logger.info(f"Updated custom subscription group '{custom_group_name}' with additional inbounds") - - # Assign the custom group to the user - user_subscription, created = UserSubscription.objects.get_or_create( - user=user, - subscription_group=custom_group, - defaults={'active': True} - ) - if created: - logger.info(f"Assigned custom subscription group to user {user.username}") - - inbound_count = selected_inbounds.count() - group_count = access_request.selected_subscription_groups.count() - - logger.info(f"Successfully assigned {group_count} subscription groups and {inbound_count} individual inbounds to user {user.username}") + logger.info(f"Successfully assigned {group_count} subscription groups to user {user.username}") except Exception as e: logger.error(f"Error assigning VPN access to user {user.username}: {e}") diff --git a/telegram_bot/bot.py b/telegram_bot/bot.py index 38274bf..1f8c869 100644 --- a/telegram_bot/bot.py +++ b/telegram_bot/bot.py @@ -6,7 +6,7 @@ import os import fcntl from typing import Optional from telegram import Update, Bot -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes from django.utils import timezone from django.conf import settings from asgiref.sync import sync_to_async @@ -125,6 +125,619 @@ class TelegramBotManager: self._release_lock() raise e + async def _is_telegram_admin(self, telegram_user): + """Check if user is a Telegram admin""" + try: + from django.db.models import Q + bot_settings = await sync_to_async(BotSettings.get_settings)() + + # Check by telegram_user_id first + if hasattr(telegram_user, 'id'): + telegram_user_id = telegram_user.id + telegram_username = telegram_user.username if hasattr(telegram_user, 'username') else None + else: + # If just an ID was passed + telegram_user_id = telegram_user + telegram_username = None + + # Check if admin by telegram_user_id + admin_by_id = await sync_to_async( + bot_settings.telegram_admins.filter(telegram_user_id=telegram_user_id).exists + )() + + if admin_by_id: + return True + + # Also check by telegram_username if available + if telegram_username: + admin_by_username = await sync_to_async( + bot_settings.telegram_admins.filter(telegram_username=telegram_username).exists + )() + + if admin_by_username: + # Update the user's telegram_user_id for future checks + admin_user = await sync_to_async( + bot_settings.telegram_admins.filter(telegram_username=telegram_username).first + )() + if admin_user and not admin_user.telegram_user_id: + admin_user.telegram_user_id = telegram_user_id + await sync_to_async(admin_user.save)() + logger.info(f"Linked telegram_user_id {telegram_user_id} to admin {admin_user.username}") + return True + + return False + except Exception as e: + logger.error(f"Error checking admin status: {e}") + return False + + async def _notify_admins_new_request(self, access_request): + """Notify all Telegram admins about new access request""" + try: + bot_settings = await sync_to_async(BotSettings.get_settings)() + admins = await sync_to_async(list)(bot_settings.telegram_admins.all()) + + if not admins: + logger.info("No Telegram admins configured, skipping notification") + return + + # Prepare user info + user_info = access_request.display_name + telegram_info = f"@{access_request.telegram_username}" if access_request.telegram_username else f"ID: {access_request.telegram_user_id}" + date = access_request.created_at.strftime("%Y-%m-%d %H:%M") + message = access_request.message_text[:200] + "..." if len(access_request.message_text) > 200 else access_request.message_text + + # Send notification to each admin + for admin in admins: + try: + if admin.telegram_user_id: + # Get admin language (default to English if not set) + admin_language = 'ru' if hasattr(admin, 'telegram_user_language') else 'en' + + notification_text = MessageLocalizer.get_message( + 'admin_new_request_notification', + admin_language, + user_info=user_info, + telegram_info=telegram_info, + date=date, + message=message + ) + + await self._send_notification_to_admin(admin.telegram_user_id, notification_text) + logger.info(f"Sent new request notification to admin {admin.username}") + + except Exception as e: + logger.error(f"Failed to notify admin {admin.username}: {e}") + + except Exception as e: + logger.error(f"Error notifying admins about new request: {e}") + + async def _send_notification_to_admin(self, telegram_user_id, message_text): + """Send notification message to specific admin""" + try: + bot_settings = await sync_to_async(BotSettings.get_settings)() + if not bot_settings.enabled or not bot_settings.bot_token: + logger.warning("Bot not configured, skipping admin notification") + return + + # Create a simple Bot instance for sending notification + from telegram import Bot + from telegram.request import HTTPXRequest + + request_kwargs = { + 'connection_pool_size': 1, + 'read_timeout': bot_settings.connection_timeout, + 'write_timeout': bot_settings.connection_timeout, + 'connect_timeout': bot_settings.connection_timeout, + } + + if bot_settings.use_proxy and bot_settings.proxy_url: + request_kwargs['proxy'] = bot_settings.proxy_url + + request = HTTPXRequest(**request_kwargs) + bot = Bot(token=bot_settings.bot_token, request=request) + + await bot.send_message( + chat_id=telegram_user_id, + text=message_text, + parse_mode='Markdown' + ) + + # Clean up bot connection + try: + await request.shutdown() + except: + pass + + except Exception as e: + logger.error(f"Failed to send notification to admin {telegram_user_id}: {e}") + + async def _create_main_keyboard(self, telegram_user): + """Create main keyboard for existing users, with admin buttons if user is admin""" + from telegram import ReplyKeyboardMarkup, KeyboardButton + + # Get basic buttons + access_button = get_localized_button(telegram_user, 'access') + guide_button = get_localized_button(telegram_user, 'guide') + + # Check if user is admin + is_admin = await self._is_telegram_admin(telegram_user) + + if is_admin: + # Admin keyboard with additional admin button + access_requests_button = get_localized_button(telegram_user, 'access_requests') + keyboard = [ + [KeyboardButton(access_button), KeyboardButton(guide_button)], + [KeyboardButton(access_requests_button)] + ] + else: + # Regular user keyboard + keyboard = [ + [KeyboardButton(access_button), KeyboardButton(guide_button)] + ] + + return ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + async def _handle_access_requests_command(self, update: Update): + """Handle access requests button - show pending requests to admin""" + try: + # Get pending access requests + pending_requests = await sync_to_async(list)( + AccessRequest.objects.filter(approved=False).order_by('-created_at') + ) + + if not pending_requests: + # No pending requests + no_requests_msg = get_localized_message(update.message.from_user, 'admin_no_pending_requests') + reply_markup = await self._create_main_keyboard(update.message.from_user) + + sent_message = await update.message.reply_text( + no_requests_msg, + reply_markup=reply_markup + ) + + await self._save_outgoing_message(sent_message, update.message.from_user) + return + + # Show pending requests with inline keyboards for approve/reject + title_msg = get_localized_message(update.message.from_user, 'admin_access_requests_title') + + # Send title message with main keyboard + reply_markup = await self._create_main_keyboard(update.message.from_user) + title_sent = await update.message.reply_text( + title_msg, + reply_markup=reply_markup, + parse_mode='Markdown' + ) + await self._save_outgoing_message(title_sent, update.message.from_user) + + # Send each request with inline keyboard for actions + from telegram import InlineKeyboardMarkup, InlineKeyboardButton + + for request in pending_requests[:5]: # Show max 5 requests + # Format request info + user_info = request.display_name + date = request.created_at.strftime("%Y-%m-%d %H:%M") + message_preview = request.message_text[:100] + "..." if len(request.message_text) > 100 else request.message_text + + request_text = get_localized_message( + update.message.from_user, + 'admin_request_item', + user_info=user_info, + date=date, + message_preview=message_preview + ) + + # Create inline keyboard for this request + approve_btn_text = get_localized_button(update.message.from_user, 'approve') + reject_btn_text = get_localized_button(update.message.from_user, 'reject') + details_btn_text = get_localized_button(update.message.from_user, 'details') + + inline_keyboard = [ + [ + InlineKeyboardButton(approve_btn_text, callback_data=f"approve_{request.id}"), + InlineKeyboardButton(reject_btn_text, callback_data=f"reject_{request.id}") + ], + [InlineKeyboardButton(details_btn_text, callback_data=f"details_{request.id}")] + ] + inline_markup = InlineKeyboardMarkup(inline_keyboard) + + request_sent = await update.message.reply_text( + request_text, + reply_markup=inline_markup, + parse_mode='Markdown' + ) + await self._save_outgoing_message(request_sent, update.message.from_user) + + if len(pending_requests) > 5: + remaining_msg = f"... и еще {len(pending_requests) - 5} заявок" if get_user_language(update.message.from_user) == 'ru' else f"... and {len(pending_requests) - 5} more requests" + await update.message.reply_text(remaining_msg) + + logger.info(f"Showed {len(pending_requests)} pending requests to admin {update.message.from_user.username}") + + except Exception as e: + logger.error(f"Error handling access requests command: {e}") + error_msg = get_localized_message(update.message.from_user, 'admin_error_processing', error=str(e)) + await update.message.reply_text(error_msg) + + async def _handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle inline keyboard button presses (callback queries)""" + try: + query = update.callback_query + await query.answer() # Acknowledge the callback + + # Check if user is admin + if not await self._is_telegram_admin(query.from_user): + await query.edit_message_text("❌ Access denied. Admin rights required.") + return + + # Parse callback data + callback_data = query.data + action, request_id = callback_data.split('_', 1) + + if action == "approve": + await self._handle_approve_callback(query, request_id) + elif action == "reject": + await self._handle_reject_callback(query, request_id) + elif action == "details": + await self._handle_details_callback(query, request_id) + elif action == "sg": # Subscription group selection + await self._handle_subscription_group_callback(query, callback_data) + elif action == "confirm": # Confirm approval with selected groups + await self._handle_confirm_approval_callback(query, request_id) + elif action == "cancel": # Cancel approval process + await self._handle_cancel_callback(query, request_id) + else: + await query.edit_message_text("❌ Unknown action") + + except Exception as e: + logger.error(f"Error handling callback query: {e}") + try: + await query.edit_message_text(f"❌ Error: {str(e)}") + except: + pass + + async def _handle_approve_callback(self, query, request_id): + """Handle approve button press - show subscription groups selection""" + try: + # Get the request + request = await sync_to_async(AccessRequest.objects.get)(id=request_id) + + # Check if already processed + if request.approved: + already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed') + await query.edit_message_text(already_processed_msg) + return + + # Get available subscription groups + from vpn.models_xray import SubscriptionGroup + groups = await sync_to_async(list)( + SubscriptionGroup.objects.filter(is_active=True).order_by('name') + ) + + if not groups: + await query.edit_message_text("❌ No subscription groups available") + return + + # Create inline keyboard with subscription groups + from telegram import InlineKeyboardMarkup, InlineKeyboardButton + + user_info = request.display_name + choose_groups_msg = get_localized_message( + query.from_user, + 'admin_choose_subscription_groups', + user_info=user_info + ) + + # Create buttons for each group (max 2 per row) + keyboard = [] + for i in range(0, len(groups), 2): + row = [] + for j in range(2): + if i + j < len(groups): + group = groups[i + j] + button_text = f"⚪ {group.name}" + callback_data = f"sg_toggle_{group.id}_{request_id}" + row.append(InlineKeyboardButton(button_text, callback_data=callback_data)) + keyboard.append(row) + + # Add confirm and cancel buttons + confirm_btn_text = get_localized_button(query.from_user, 'confirm_approval') + cancel_btn_text = get_localized_button(query.from_user, 'cancel') + + keyboard.append([ + InlineKeyboardButton(confirm_btn_text, callback_data=f"confirm_{request_id}"), + InlineKeyboardButton(cancel_btn_text, callback_data=f"cancel_{request_id}") + ]) + + reply_markup = InlineKeyboardMarkup(keyboard) + await query.edit_message_text(choose_groups_msg, reply_markup=reply_markup, parse_mode='Markdown') + + except Exception as e: + logger.error(f"Error handling approve callback: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + + async def _handle_subscription_group_callback(self, query, callback_data): + """Handle subscription group toggle button""" + try: + # Parse callback data: sg_toggle_{group_id}_{request_id} + parts = callback_data.split('_') + group_id = parts[2] + request_id = parts[3] + + # Get current message text and keyboard + current_text = query.message.text + current_keyboard = query.message.reply_markup.inline_keyboard + + # Toggle the group selection + updated_keyboard = [] + for row in current_keyboard: + updated_row = [] + for button in row: + if button.callback_data and button.callback_data.startswith(f"sg_toggle_{group_id}_"): + # Toggle this button + if button.text.startswith("⚪"): + # Select it + new_text = button.text.replace("⚪", "✅") + else: + # Deselect it + new_text = button.text.replace("✅", "⚪") + from telegram import InlineKeyboardButton + updated_row.append(InlineKeyboardButton(new_text, callback_data=button.callback_data)) + else: + updated_row.append(button) + updated_keyboard.append(updated_row) + + from telegram import InlineKeyboardMarkup + updated_markup = InlineKeyboardMarkup(updated_keyboard) + await query.edit_message_reply_markup(reply_markup=updated_markup) + + except Exception as e: + logger.error(f"Error handling subscription group callback: {e}") + + async def _handle_confirm_approval_callback(self, query, request_id): + """Handle confirm approval button - create user and assign groups""" + try: + # Get the request + request = await sync_to_async(AccessRequest.objects.get)(id=request_id) + + # Check if already processed + if request.approved: + already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed') + await query.edit_message_text(already_processed_msg) + return + + # Get selected groups from the keyboard + selected_groups = [] + from vpn.models_xray import SubscriptionGroup + + for row in query.message.reply_markup.inline_keyboard: + for button in row: + if button.callback_data and button.callback_data.startswith("sg_toggle_") and button.text.startswith("✅"): + # Extract group_id from callback_data + group_id = button.callback_data.split('_')[2] + group = await sync_to_async(SubscriptionGroup.objects.get)(id=group_id) + selected_groups.append(group) + + if not selected_groups: + await query.edit_message_text("❌ Please select at least one subscription group") + return + + # Save selected groups to the request + await sync_to_async(request.selected_subscription_groups.set)(selected_groups) + + try: + # Create or get user + from vpn.models import User + from vpn.models_xray import UserSubscription + import secrets + import string + + # Check if user already exists + existing_user = await sync_to_async( + User.objects.filter(telegram_user_id=request.telegram_user_id).first + )() + + if existing_user: + user = existing_user + logger.info(f"Using existing user {user.username} for telegram_user_id {request.telegram_user_id}") + else: + # Check if selected_existing_user is set + if request.selected_existing_user: + # Link telegram to existing user + user = request.selected_existing_user + user.telegram_user_id = request.telegram_user_id + user.telegram_username = request.telegram_username + await sync_to_async(user.save)() + logger.info(f"Linked telegram account to existing user {user.username}") + else: + # Create new user + username = request.desired_username or request.telegram_username or f"tg_{request.telegram_user_id}" + + # Ensure unique username + base_username = username + counter = 1 + while await sync_to_async(User.objects.filter(username=username).exists)(): + username = f"{base_username}_{counter}" + counter += 1 + + # Generate random password + alphabet = string.ascii_letters + string.digits + password = ''.join(secrets.choice(alphabet) for _ in range(16)) + + # Create user + user = await sync_to_async(User.objects.create_user)( + username=username, + password=password, + telegram_user_id=request.telegram_user_id, + telegram_username=request.telegram_username, + first_name=request.telegram_first_name or '', + last_name=request.telegram_last_name or '', + comment=f"Created from Telegram request #{request.id}" + ) + logger.info(f"Created new user {user.username} from Telegram request") + + # Link user to request + request.user = user + await sync_to_async(request.save)() + + # Assign subscription groups to user + for subscription_group in selected_groups: + user_subscription, created = await sync_to_async(UserSubscription.objects.get_or_create)( + user=user, + subscription_group=subscription_group, + defaults={'active': True} + ) + if created: + logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}") + else: + # Ensure it's active if it already existed + if not user_subscription.active: + user_subscription.active = True + await sync_to_async(user_subscription.save)() + logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}") + + # Mark as approved + request.approved = True + await sync_to_async(request.save)() + + # Send success message to admin + groups_list = ", ".join([group.name for group in selected_groups]) + success_msg = get_localized_message( + query.from_user, + 'admin_approval_success', + user_info=request.display_name, + groups=groups_list + ) + await query.edit_message_text(success_msg, parse_mode='Markdown') + + # Send approval notification to user + # Create a dummy user object for localization + class DummyUser: + def __init__(self, lang): + self.language_code = lang + + user_lang = DummyUser(request.user_language if hasattr(request, 'user_language') else 'en') + approval_msg = get_localized_message(user_lang, 'approval_notification') + await self._send_notification_to_admin(request.telegram_user_id, approval_msg) + + logger.info(f"Admin {query.from_user.username} approved request {request_id} with {len(selected_groups)} groups") + + except Exception as e: + logger.error(f"Error in approval process: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + + except Exception as e: + logger.error(f"Error handling confirm approval callback: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + + async def _handle_reject_callback(self, query, request_id): + """Handle reject button press""" + try: + # Get the request + request = await sync_to_async(AccessRequest.objects.get)(id=request_id) + + # Check if already processed + if request.approved: + already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed') + await query.edit_message_text(already_processed_msg) + return + + # Mark as rejected (we can add a rejected field or just delete) + await sync_to_async(request.delete)() + + # Send success message to admin + success_msg = get_localized_message( + query.from_user, + 'admin_rejection_success', + user_info=request.display_name + ) + await query.edit_message_text(success_msg, parse_mode='Markdown') + + logger.info(f"Admin {query.from_user.username} rejected request {request_id}") + + except Exception as e: + logger.error(f"Error handling reject callback: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + + async def _handle_details_callback(self, query, request_id): + """Handle details button press - show full request info""" + try: + # Get the request + request = await sync_to_async(AccessRequest.objects.get)(id=request_id) + + # Format detailed information + details = f"**📋 Request Details**\n\n" + details += f"**👤 User:** {request.display_name}\n" + details += f"**📱 Telegram:** @{request.telegram_username}" if request.telegram_username else f"**📱 Telegram ID:** {request.telegram_user_id}\n" + details += f"**📅 Date:** {request.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n" + details += f"**🆔 Request ID:** {request.id}\n" + details += f"**👤 Desired Username:** {request.desired_username}\n\n" + details += f"**💬 Full Message:**\n{request.message_text}" + + # Create back button + from telegram import InlineKeyboardMarkup, InlineKeyboardButton + back_keyboard = [[ + InlineKeyboardButton("⬅️ Back", callback_data=f"approve_{request_id}"), + InlineKeyboardButton("❌ Reject", callback_data=f"reject_{request_id}") + ]] + reply_markup = InlineKeyboardMarkup(back_keyboard) + + await query.edit_message_text(details, reply_markup=reply_markup, parse_mode='Markdown') + + except Exception as e: + logger.error(f"Error handling details callback: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + + async def _handle_cancel_callback(self, query, request_id): + """Handle cancel button press - return to original request view""" + try: + # Get the request + request = await sync_to_async(AccessRequest.objects.get)(id=request_id) + + # Recreate original request display + user_info = request.display_name + date = request.created_at.strftime("%Y-%m-%d %H:%M") + message_preview = request.message_text[:100] + "..." if len(request.message_text) > 100 else request.message_text + + request_text = get_localized_message( + query.from_user, + 'admin_request_item', + user_info=user_info, + date=date, + message_preview=message_preview + ) + + # Create original inline keyboard + from telegram import InlineKeyboardMarkup, InlineKeyboardButton + approve_btn_text = get_localized_button(query.from_user, 'approve') + reject_btn_text = get_localized_button(query.from_user, 'reject') + details_btn_text = get_localized_button(query.from_user, 'details') + + inline_keyboard = [ + [ + InlineKeyboardButton(approve_btn_text, callback_data=f"approve_{request.id}"), + InlineKeyboardButton(reject_btn_text, callback_data=f"reject_{request.id}") + ], + [InlineKeyboardButton(details_btn_text, callback_data=f"details_{request.id}")] + ] + inline_markup = InlineKeyboardMarkup(inline_keyboard) + + await query.edit_message_text(request_text, reply_markup=inline_markup, parse_mode='Markdown') + + except Exception as e: + logger.error(f"Error handling cancel callback: {e}") + error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e)) + await query.edit_message_text(error_msg) + def stop(self): """Stop the bot""" if not self._running: @@ -217,6 +830,7 @@ class TelegramBotManager: # Add handlers self._application.add_handler(CommandHandler("start", self._handle_start)) + self._application.add_handler(CallbackQueryHandler(self._handle_callback_query)) self._application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message)) self._application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._handle_other)) @@ -276,19 +890,7 @@ class TelegramBotManager: if user_response['action'] == 'existing_user': # User already exists - show keyboard with options - from telegram import ReplyKeyboardMarkup, KeyboardButton - - # Create keyboard for registered users with localized buttons - access_button = get_localized_button(update.message.from_user, 'access') - guide_button = get_localized_button(update.message.from_user, 'guide') - keyboard = [ - [KeyboardButton(access_button), KeyboardButton(guide_button)], - ] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True, - one_time_keyboard=False - ) + reply_markup = await self._create_main_keyboard(update.message.from_user) help_text = get_localized_message(update.message.from_user, 'help_text') sent_message = await update.message.reply_text( @@ -331,6 +933,7 @@ class TelegramBotManager: # Get localized button texts for comparison access_btn = get_localized_button(update.message.from_user, 'access') guide_btn = get_localized_button(update.message.from_user, 'guide') + access_requests_btn = get_localized_button(update.message.from_user, 'access_requests') all_in_one_btn = get_localized_button(update.message.from_user, 'all_in_one') back_btn = get_localized_button(update.message.from_user, 'back') group_prefix = get_localized_button(update.message.from_user, 'group_prefix') @@ -340,6 +943,12 @@ class TelegramBotManager: await self._handle_access_command(update, user_response['user']) elif update.message.text == guide_btn: await self._handle_guide_command(update) + elif update.message.text == access_requests_btn: + # Admin command - check permissions and handle + if await self._is_telegram_admin(update.message.from_user): + await self._handle_access_requests_command(update) + else: + await self._send_help_message(update) elif update.message.text.startswith(group_prefix): # Handle specific group selection group_name = update.message.text.replace(group_prefix, "") @@ -653,16 +1262,7 @@ class TelegramBotManager: ) else: # No active subscriptions - show main keyboard - access_button = get_localized_button(update.message.from_user, 'access') - guide_button = get_localized_button(update.message.from_user, 'guide') - keyboard = [ - [KeyboardButton(access_button), KeyboardButton(guide_button)], - ] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True, - one_time_keyboard=False - ) + reply_markup = await self._create_main_keyboard(update.message.from_user) no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions') sent_message = await update.message.reply_text( @@ -711,6 +1311,10 @@ class TelegramBotManager: ) logger.info(f"Created access request for user {telegram_user.username or telegram_user.id}") + + # Notify admins about new request + await self._notify_admins_new_request(request) + return request except Exception as e: @@ -721,17 +1325,7 @@ class TelegramBotManager: """Send help message for unrecognized commands""" try: # Create main keyboard for existing users with localized buttons - from telegram import ReplyKeyboardMarkup, KeyboardButton - access_button = get_localized_button(update.message.from_user, 'access') - guide_button = get_localized_button(update.message.from_user, 'guide') - keyboard = [ - [KeyboardButton(access_button), KeyboardButton(guide_button)], - ] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True, - one_time_keyboard=False - ) + reply_markup = await self._create_main_keyboard(update.message.from_user) help_text = get_localized_message(update.message.from_user, 'help_text') sent_message = await update.message.reply_text( @@ -978,19 +1572,8 @@ class TelegramBotManager: async def _handle_back_to_main(self, update: Update, user): """Handle back button - return to main menu""" try: - from telegram import ReplyKeyboardMarkup, KeyboardButton - # Create main keyboard with localized buttons - access_btn = get_localized_button(update.message.from_user, 'access') - guide_btn = get_localized_button(update.message.from_user, 'guide') - keyboard = [ - [KeyboardButton(access_btn), KeyboardButton(guide_btn)], - ] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True, - one_time_keyboard=False - ) + reply_markup = await self._create_main_keyboard(update.message.from_user) help_text = get_localized_message(update.message.from_user, 'help_text') sent_message = await update.message.reply_text( diff --git a/telegram_bot/localization.py b/telegram_bot/localization.py index c0532cf..2744e07 100644 --- a/telegram_bot/localization.py +++ b/telegram_bot/localization.py @@ -41,6 +41,18 @@ MESSAGES = { 'guide_choose_platform': "Select your device platform:", 'web_portal_description': "_Web portal shows your access list on one convenient page with some statistics._", 'servers_in_group': "🔒 **Servers in group:**", + + # Admin messages + 'admin_new_request_notification': "🔔 **New Access Request**\n\n👤 **User:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Date:** {date}\n\n💬 **Message:** {message}", + 'admin_access_requests_title': "📋 **Pending Access Requests**", + 'admin_no_pending_requests': "✅ No pending access requests", + 'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_", + 'admin_choose_subscription_groups': "📦 **Choose Subscription Groups for {user_info}:**\n\nSelect groups to assign to this user:", + 'admin_approval_success': "✅ **Request Approved!**\n\n👤 User: {user_info}\n📦 Groups: {groups}\n\nUser has been notified and given access.", + 'admin_rejection_success': "❌ **Request Rejected**\n\n👤 User: {user_info}\n\nUser has been notified.", + 'admin_request_already_processed': "⚠️ This request has already been processed by another admin.", + 'admin_error_processing': "❌ Error processing request: {error}", + 'android_guide': "🤖 **Android Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites\n• You can choose specific apps to use VPN while others use direct connection\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.", 'ios_guide': " **iOS Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**⚠️ Note for iOS users:**\nCurrently, only VLESS protocol works reliably on iOS. Other protocols may have connectivity issues.\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites to improve performance\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.", 'buttons': { @@ -52,7 +64,15 @@ MESSAGES = { 'all_in_one': "🌍 All-in-one", 'back': "⬅️ Back", 'group_prefix': "Group: ", - 'request_access': "🔑 Request Access" + 'request_access': "🔑 Request Access", + # Admin buttons + 'access_requests': "📋 Access Requests", + 'approve': "✅ Approve", + 'reject': "❌ Reject", + 'details': "👁 Details", + 'confirm_approval': "✅ Confirm Approval", + 'confirm_rejection': "❌ Confirm Rejection", + 'cancel': "🚫 Cancel" } }, 'ru': { @@ -88,6 +108,18 @@ MESSAGES = { 'guide_choose_platform': "Выберите платформу вашего устройства:", 'web_portal_description': "_Веб-портал показывает список ваших доступов на одной удобной странице с некоторой статистикой._", 'servers_in_group': "🔒 **Серверы в группе:**", + + # Admin messages + 'admin_new_request_notification': "🔔 **Новый запрос на доступ**\n\n👤 **Пользователь:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Дата:** {date}\n\n💬 **Сообщение:** {message}", + 'admin_access_requests_title': "📋 **Ожидающие запросы на доступ**", + 'admin_no_pending_requests': "✅ Нет ожидающих запросов на доступ", + 'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_", + 'admin_choose_subscription_groups': "📦 **Выберите группы подписки для {user_info}:**\n\nВыберите группы для назначения этому пользователю:", + 'admin_approval_success': "✅ **Запрос одобрен!**\n\n👤 Пользователь: {user_info}\n📦 Группы: {groups}\n\nПользователь уведомлен и получил доступ.", + 'admin_rejection_success': "❌ **Запрос отклонен**\n\n👤 Пользователь: {user_info}\n\nПользователь уведомлен.", + 'admin_request_already_processed': "⚠️ Этот запрос уже обработан другим администратором.", + 'admin_error_processing': "❌ Ошибка обработки запроса: {error}", + 'android_guide': "🤖 **Руководство для Android**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов\n• Вы можете выбрать конкретные приложения для использования VPN, в то время как остальные будут работать напрямую\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.", 'ios_guide': " **Руководство для iOS**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**⚠️ Важно для пользователей iOS:**\nВ настоящее время на iOS стабильно работает только протокол VLESS. Другие протоколы могут иметь проблемы с подключением.\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов для улучшения производительности\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.", 'buttons': { @@ -99,7 +131,15 @@ MESSAGES = { 'all_in_one': "🌍 Все в одном", 'back': "⬅️ Назад", 'group_prefix': "Группа: ", - 'request_access': "🔑 Запросить доступ" + 'request_access': "🔑 Запросить доступ", + # Admin buttons + 'access_requests': "📋 Запросы на доступ", + 'approve': "✅ Одобрить", + 'reject': "❌ Отклонить", + 'details': "👁 Подробности", + 'confirm_approval': "✅ Подтвердить одобрение", + 'confirm_rejection': "❌ Подтвердить отклонение", + 'cancel': "🚫 Отмена" } } } diff --git a/telegram_bot/migrations/0010_botsettings_telegram_admins.py b/telegram_bot/migrations/0010_botsettings_telegram_admins.py new file mode 100644 index 0000000..ac441c4 --- /dev/null +++ b/telegram_bot/migrations/0010_botsettings_telegram_admins.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.7 on 2025-08-15 13:00 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0009_accessrequest_selected_inbounds_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='botsettings', + name='telegram_admins', + field=models.ManyToManyField(blank=True, help_text='Users with linked Telegram accounts who will have admin access in the bot', related_name='bot_admin_settings', to=settings.AUTH_USER_MODEL), + ), + ] \ No newline at end of file diff --git a/telegram_bot/models.py b/telegram_bot/models.py index ef03672..6dbeb1b 100644 --- a/telegram_bot/models.py +++ b/telegram_bot/models.py @@ -33,6 +33,12 @@ class BotSettings(models.Model): default=30, help_text="Connection timeout in seconds" ) + telegram_admins = models.ManyToManyField( + User, + blank=True, + related_name='bot_admin_settings', + help_text="Users with linked Telegram accounts who will have admin access in the bot" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/vpn/admin.py b/vpn/admin.py index 970efbd..2b14fb2 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -157,12 +157,10 @@ except ImportError: # Add subscription management to User admin try: - from vpn.admin.user import add_subscription_management_to_user - from django.contrib.admin import site - for model, admin_instance in site._registry.items(): - if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'): - add_subscription_management_to_user(admin_instance.__class__) - break + from vpn.admin_xray import add_subscription_management_to_user + from vpn.admin.user import UserAdmin + add_subscription_management_to_user(UserAdmin) + logger.info("✅ Successfully added subscription management to User admin") except Exception as e: logger.error(f"Failed to add subscription management: {e}") diff --git a/vpn/admin/user.py b/vpn/admin/user.py index 58d5531..ce99f46 100644 --- a/vpn/admin/user.py +++ b/vpn/admin/user.py @@ -23,8 +23,8 @@ class UserAdmin(BaseVPNAdmin): form = UserForm list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count') search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username') - readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display') - inlines = [] # All VPN access info is now in vpn_access_summary + readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display', 'subscription_management_info') + inlines = [] # Inlines will be added by subscription management function fieldsets = ( ('User Information', { @@ -42,6 +42,11 @@ class UserAdmin(BaseVPNAdmin): 'fields': ('user_statistics_summary',), 'classes': ('wide',) }), + ('Subscription Management', { + 'fields': ('subscription_management_info',), + 'classes': ('wide',), + 'description': 'Manage user\'s Xray subscription groups. Use the "User\'s Subscription Groups" section below to add/remove subscriptions.' + }), ) @admin.display(description='VPN Access Summary') @@ -450,6 +455,99 @@ class UserAdmin(BaseVPNAdmin): html += '' return mark_safe(html) + @admin.display(description='Subscription Management') + def subscription_management_info(self, obj): + """Display subscription management information and quick access""" + if not obj.pk: + return "Save user first to manage subscriptions" + + try: + from vpn.models_xray import UserSubscription, SubscriptionGroup + + # Get user's current subscriptions + user_subscriptions = UserSubscription.objects.filter(user=obj).select_related('subscription_group') + active_subs = user_subscriptions.filter(active=True) + inactive_subs = user_subscriptions.filter(active=False) + + # Get available subscription groups + all_groups = SubscriptionGroup.objects.filter(is_active=True) + subscribed_group_ids = user_subscriptions.values_list('subscription_group_id', flat=True) + available_groups = all_groups.exclude(id__in=subscribed_group_ids) + + html = '
' + html += '

🚀 Xray Subscription Management

' + + # Active subscriptions + if active_subs.exists(): + html += '
' + html += '
✅ Active Subscriptions
' + for sub in active_subs: + html += f'
' + html += f'{sub.subscription_group.name}' + if sub.subscription_group.description: + html += f' - {sub.subscription_group.description[:50]}{"..." if len(sub.subscription_group.description) > 50 else ""}' + html += f'' + html += f'Since: {sub.created_at.strftime("%Y-%m-%d")}' + html += f'
' + html += '
' + + # Inactive subscriptions + if inactive_subs.exists(): + html += '
' + html += '
❌ Inactive Subscriptions
' + for sub in inactive_subs: + html += f'
' + html += f'{sub.subscription_group.name}' + html += f'
' + html += '
' + + # Available subscription groups + if available_groups.exists(): + html += '
' + html += '
➕ Available Subscription Groups
' + html += '
' + for group in available_groups[:10]: # Limit to avoid clutter + html += f'' + html += f'{group.name}' + html += f'' + if available_groups.count() > 10: + html += f'+{available_groups.count() - 10} more...' + html += '
' + html += '
' + + # Quick access links + html += '
' + html += '
🔗 Quick Access
' + html += '
' + + # Link to standalone UserSubscription admin + subscription_admin_url = f"/admin/vpn/usersubscription/?user__id__exact={obj.id}" + html += f'📋 Manage All Subscriptions' + + # Link to add new subscription + add_subscription_url = f"/admin/vpn/usersubscription/add/?user={obj.id}" + html += f'➕ Add New Subscription' + + # Link to subscription groups admin + groups_admin_url = "/admin/vpn/subscriptiongroup/" + html += f'⚙️ Manage Groups' + + html += '
' + html += '
' + + # Statistics + total_subs = user_subscriptions.count() + if total_subs > 0: + html += '
' + html += f'📊 Total: {total_subs} subscription(s) | Active: {active_subs.count()} | Inactive: {inactive_subs.count()}' + html += '
' + + html += '
' + return mark_safe(html) + + except Exception as e: + return mark_safe(f'
❌ Error loading subscription management: {e}
') + @admin.display(description='Allowed servers', ordering='server_count') def server_count(self, obj): return obj.server_count diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index 8f62cbb..38c4254 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -614,14 +614,15 @@ class UserSubscriptionInline(admin.TabularInline): # Extension for User admin -def add_subscription_management_to_user(UserAdmin): +def add_subscription_management_to_user(UserAdminClass): """Add subscription management to existing User admin""" - # Add inline only - no fieldset or widget - if hasattr(UserAdmin, 'inlines'): - UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline] + # Add inline to the User admin class + if hasattr(UserAdminClass, 'inlines'): + if UserSubscriptionInline not in UserAdminClass.inlines: + UserAdminClass.inlines = list(UserAdminClass.inlines) + [UserSubscriptionInline] else: - UserAdmin.inlines = [UserSubscriptionInline] + UserAdminClass.inlines = [UserSubscriptionInline] # UserSubscription admin will be integrated into unified Subscriptions admin