mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
1061 lines
45 KiB
Python
1061 lines
45 KiB
Python
![]() |
import logging
|
||
|
import threading
|
||
|
import time
|
||
|
import asyncio
|
||
|
import os
|
||
|
import fcntl
|
||
|
from typing import Optional
|
||
|
from telegram import Update, Bot
|
||
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||
|
from django.utils import timezone
|
||
|
from django.conf import settings
|
||
|
from asgiref.sync import sync_to_async
|
||
|
from .models import BotSettings, TelegramMessage, AccessRequest
|
||
|
from .localization import get_localized_message, get_localized_button, get_user_language, MessageLocalizer
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
class TelegramBotManager:
|
||
|
"""Singleton manager for Telegram bot with file locking"""
|
||
|
_instance = None
|
||
|
_lock = threading.Lock()
|
||
|
_bot_thread: Optional[threading.Thread] = None
|
||
|
_application: Optional[Application] = None
|
||
|
_stop_event = threading.Event()
|
||
|
_lockfile = None
|
||
|
|
||
|
def __new__(cls):
|
||
|
if cls._instance is None:
|
||
|
with cls._lock:
|
||
|
if cls._instance is None:
|
||
|
cls._instance = super().__new__(cls)
|
||
|
return cls._instance
|
||
|
|
||
|
def __init__(self):
|
||
|
# Initialize only once
|
||
|
if not hasattr(self, '_initialized'):
|
||
|
self._initialized = True
|
||
|
self._running = False
|
||
|
self._lockfile = None
|
||
|
# Sync status on startup
|
||
|
self._sync_status_on_startup()
|
||
|
|
||
|
def _acquire_lock(self):
|
||
|
"""Acquire file lock to prevent multiple bot instances"""
|
||
|
try:
|
||
|
# Create lock file path
|
||
|
lock_dir = os.path.join(getattr(settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
|
||
|
os.makedirs(lock_dir, exist_ok=True)
|
||
|
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
|
||
|
|
||
|
# Open lock file
|
||
|
self._lockfile = open(lock_path, 'w')
|
||
|
|
||
|
# Try to acquire exclusive lock (non-blocking)
|
||
|
fcntl.flock(self._lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
|
|
||
|
# Write PID to lock file
|
||
|
self._lockfile.write(f"{os.getpid()}\n")
|
||
|
self._lockfile.flush()
|
||
|
|
||
|
logger.info(f"Acquired bot lock: {lock_path}")
|
||
|
return True
|
||
|
|
||
|
except (OSError, IOError) as e:
|
||
|
if self._lockfile:
|
||
|
try:
|
||
|
self._lockfile.close()
|
||
|
except:
|
||
|
pass
|
||
|
self._lockfile = None
|
||
|
|
||
|
logger.warning(f"Could not acquire bot lock: {e}")
|
||
|
return False
|
||
|
|
||
|
def _release_lock(self):
|
||
|
"""Release file lock"""
|
||
|
if self._lockfile:
|
||
|
try:
|
||
|
fcntl.flock(self._lockfile.fileno(), fcntl.LOCK_UN)
|
||
|
self._lockfile.close()
|
||
|
logger.info("Released bot lock")
|
||
|
except:
|
||
|
pass
|
||
|
finally:
|
||
|
self._lockfile = None
|
||
|
|
||
|
def start(self):
|
||
|
"""Start the bot in a background thread"""
|
||
|
if self._running:
|
||
|
logger.warning("Bot is already running")
|
||
|
return
|
||
|
|
||
|
# Try to acquire lock first
|
||
|
if not self._acquire_lock():
|
||
|
raise Exception("Another bot instance is already running (could not acquire lock)")
|
||
|
|
||
|
# No database status to reset - only lock file matters
|
||
|
|
||
|
bot_settings = BotSettings.get_settings()
|
||
|
if not bot_settings.enabled:
|
||
|
self._release_lock()
|
||
|
raise Exception("Bot is disabled in settings")
|
||
|
|
||
|
if not bot_settings.bot_token:
|
||
|
self._release_lock()
|
||
|
raise Exception("Bot token is not configured")
|
||
|
|
||
|
try:
|
||
|
self._stop_event.clear()
|
||
|
self._bot_thread = threading.Thread(target=self._run_bot, daemon=True)
|
||
|
self._bot_thread.start()
|
||
|
|
||
|
# Wait a moment to see if thread starts successfully
|
||
|
time.sleep(0.5)
|
||
|
|
||
|
# Check if thread started successfully
|
||
|
if self._bot_thread.is_alive():
|
||
|
logger.info("Bot started successfully")
|
||
|
else:
|
||
|
self._release_lock()
|
||
|
raise Exception("Bot thread failed to start")
|
||
|
|
||
|
except Exception as e:
|
||
|
self._release_lock()
|
||
|
raise e
|
||
|
|
||
|
def stop(self):
|
||
|
"""Stop the bot"""
|
||
|
if not self._running:
|
||
|
logger.warning("Bot is not running")
|
||
|
return
|
||
|
|
||
|
logger.info("Stopping bot...")
|
||
|
self._stop_event.set()
|
||
|
|
||
|
if self._application:
|
||
|
# Stop the application
|
||
|
try:
|
||
|
self._application.stop_running()
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error stopping application: {e}")
|
||
|
|
||
|
# Wait for thread to finish
|
||
|
if self._bot_thread and self._bot_thread.is_alive():
|
||
|
self._bot_thread.join(timeout=10)
|
||
|
|
||
|
self._running = False
|
||
|
|
||
|
# Release file lock
|
||
|
self._release_lock()
|
||
|
|
||
|
logger.info("Bot stopped")
|
||
|
|
||
|
def restart(self):
|
||
|
"""Restart the bot"""
|
||
|
logger.info("Restarting bot...")
|
||
|
self.stop()
|
||
|
time.sleep(2) # Wait a bit before restarting
|
||
|
self.start()
|
||
|
|
||
|
def _run_bot(self):
|
||
|
"""Run the bot (in background thread with asyncio loop)"""
|
||
|
try:
|
||
|
# Create new event loop for this thread
|
||
|
loop = asyncio.new_event_loop()
|
||
|
asyncio.set_event_loop(loop)
|
||
|
|
||
|
# Run the async bot
|
||
|
loop.run_until_complete(self._async_run_bot())
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Bot error: {e}")
|
||
|
self._running = False
|
||
|
# Release lock on error
|
||
|
self._release_lock()
|
||
|
finally:
|
||
|
try:
|
||
|
loop.close()
|
||
|
except:
|
||
|
pass
|
||
|
|
||
|
async def _async_run_bot(self):
|
||
|
"""Async bot runner"""
|
||
|
try:
|
||
|
self._running = True
|
||
|
settings = await sync_to_async(BotSettings.get_settings)()
|
||
|
|
||
|
# Create application with custom request settings
|
||
|
from telegram.request import HTTPXRequest
|
||
|
|
||
|
# Prepare request settings
|
||
|
request_kwargs = {
|
||
|
'connection_pool_size': 8,
|
||
|
'read_timeout': settings.connection_timeout,
|
||
|
'write_timeout': settings.connection_timeout,
|
||
|
'connect_timeout': settings.connection_timeout,
|
||
|
'pool_timeout': settings.connection_timeout
|
||
|
}
|
||
|
|
||
|
# Add proxy if configured
|
||
|
if settings.use_proxy and settings.proxy_url:
|
||
|
logger.info(f"Using proxy: {settings.proxy_url}")
|
||
|
request_kwargs['proxy'] = settings.proxy_url
|
||
|
|
||
|
request = HTTPXRequest(**request_kwargs)
|
||
|
|
||
|
# Create application builder
|
||
|
app_builder = Application.builder().token(settings.bot_token).request(request)
|
||
|
|
||
|
# Use custom API base URL if provided
|
||
|
if settings.api_base_url and settings.api_base_url != "https://api.telegram.org":
|
||
|
logger.info(f"Using custom API base URL: {settings.api_base_url}")
|
||
|
app_builder = app_builder.base_url(settings.api_base_url)
|
||
|
|
||
|
self._application = app_builder.build()
|
||
|
|
||
|
# Add handlers
|
||
|
self._application.add_handler(CommandHandler("start", self._handle_start))
|
||
|
self._application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
|
||
|
self._application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._handle_other))
|
||
|
|
||
|
# Initialize application
|
||
|
await self._application.initialize()
|
||
|
await self._application.start()
|
||
|
await self._application.updater.start_polling(
|
||
|
allowed_updates=Update.ALL_TYPES,
|
||
|
drop_pending_updates=True
|
||
|
)
|
||
|
|
||
|
logger.info("Bot polling started successfully")
|
||
|
|
||
|
# Test connection
|
||
|
try:
|
||
|
logger.info("Testing bot connection...")
|
||
|
bot = self._application.bot
|
||
|
me = await bot.get_me()
|
||
|
logger.info(f"Bot connected successfully. Bot info: @{me.username} ({me.first_name})")
|
||
|
except Exception as test_e:
|
||
|
logger.error(f"Bot connection test failed: {test_e}")
|
||
|
logger.error(f"Connection settings - API URL: {settings.api_base_url}, Timeout: {settings.connection_timeout}s")
|
||
|
if settings.use_proxy:
|
||
|
logger.error(f"Proxy settings - URL: {settings.proxy_url}")
|
||
|
raise
|
||
|
|
||
|
# Keep running until stop event is set
|
||
|
while not self._stop_event.is_set():
|
||
|
await asyncio.sleep(1)
|
||
|
|
||
|
logger.info("Stop event received, shutting down...")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Async bot error: {e}")
|
||
|
raise
|
||
|
finally:
|
||
|
# Clean shutdown
|
||
|
if self._application:
|
||
|
try:
|
||
|
await self._application.updater.stop()
|
||
|
await self._application.stop()
|
||
|
await self._application.shutdown()
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error during shutdown: {e}")
|
||
|
self._running = False
|
||
|
# Release lock on shutdown
|
||
|
self._release_lock()
|
||
|
|
||
|
async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
"""Handle /start command"""
|
||
|
try:
|
||
|
# Save incoming message
|
||
|
saved_message = await self._save_message(update.message, 'incoming')
|
||
|
|
||
|
# Check if user exists by telegram_user_id
|
||
|
user_response = await self._check_user_access(update.message.from_user)
|
||
|
|
||
|
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')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(access_button)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
help_text = get_localized_message(update.message.from_user, 'help_text')
|
||
|
sent_message = await update.message.reply_text(
|
||
|
help_text,
|
||
|
reply_markup=reply_markup
|
||
|
)
|
||
|
logger.info(f"Handled /start from existing user {user_response['user'].username}")
|
||
|
|
||
|
elif user_response['action'] == 'show_new_user_keyboard':
|
||
|
# Show keyboard with request access button for new users
|
||
|
await self._show_new_user_keyboard(update)
|
||
|
|
||
|
elif user_response['action'] == 'pending_request':
|
||
|
# Show pending request message
|
||
|
await self._show_pending_request_message(update)
|
||
|
else:
|
||
|
# Fallback case - show new user keyboard
|
||
|
await self._show_new_user_keyboard(update)
|
||
|
|
||
|
# Save outgoing message (if sent_message was created)
|
||
|
if 'sent_message' in locals():
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling /start: {e}")
|
||
|
|
||
|
async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
"""Handle text messages"""
|
||
|
try:
|
||
|
# Save incoming message
|
||
|
saved_message = await self._save_message(update.message, 'incoming')
|
||
|
|
||
|
# Check if user exists by telegram_user_id
|
||
|
user_response = await self._check_user_access(update.message.from_user)
|
||
|
|
||
|
if user_response['action'] == 'existing_user':
|
||
|
# Get localized button texts for comparison
|
||
|
access_btn = get_localized_button(update.message.from_user, 'access')
|
||
|
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')
|
||
|
|
||
|
# Check if this is a keyboard command
|
||
|
if update.message.text == access_btn:
|
||
|
await self._handle_access_command(update, user_response['user'])
|
||
|
elif update.message.text.startswith(group_prefix):
|
||
|
# Handle specific group selection
|
||
|
group_name = update.message.text.replace(group_prefix, "")
|
||
|
await self._handle_group_selection(update, user_response['user'], group_name)
|
||
|
elif update.message.text == all_in_one_btn:
|
||
|
# Handle All-in-one selection
|
||
|
await self._handle_all_in_one(update, user_response['user'])
|
||
|
elif update.message.text == back_btn:
|
||
|
# Handle back button
|
||
|
await self._handle_back_to_main(update, user_response['user'])
|
||
|
else:
|
||
|
# Unrecognized command - send help message
|
||
|
await self._send_help_message(update)
|
||
|
return
|
||
|
|
||
|
elif user_response['action'] == 'show_new_user_keyboard':
|
||
|
# Check if user clicked "Request Access" button
|
||
|
request_access_btn = get_localized_button(update.message.from_user, 'request_access')
|
||
|
|
||
|
if update.message.text == request_access_btn:
|
||
|
# User clicked request access - create request
|
||
|
await self._create_access_request(update.message.from_user, update.message, saved_message)
|
||
|
request_created_msg = get_localized_message(update.message.from_user, 'access_request_created')
|
||
|
sent_message = await update.message.reply_text(request_created_msg)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(sent_message, update.message.from_user)
|
||
|
logger.info(f"Created access request for user {update.message.from_user.username or update.message.from_user.id}")
|
||
|
else:
|
||
|
# User sent other text - show keyboard again
|
||
|
await self._show_new_user_keyboard(update)
|
||
|
return
|
||
|
|
||
|
elif user_response['action'] == 'pending_request':
|
||
|
# Show pending request message
|
||
|
await self._show_pending_request_message(update)
|
||
|
return
|
||
|
|
||
|
elif user_response['action'] == 'create_request':
|
||
|
# Create new access request with this message
|
||
|
await self._create_access_request(update.message.from_user, update.message, saved_message)
|
||
|
request_created_msg = get_localized_message(update.message.from_user, 'access_request_created')
|
||
|
sent_message = await update.message.reply_text(
|
||
|
request_created_msg
|
||
|
)
|
||
|
logger.info(f"Created access request for new user {update.message.from_user.username or update.message.from_user.id}")
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling message: {e}")
|
||
|
|
||
|
async def _handle_other(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
"""Handle non-text messages (photos, documents, etc.)"""
|
||
|
try:
|
||
|
# Save incoming message
|
||
|
await self._save_message(update.message, 'incoming')
|
||
|
|
||
|
# Auto-reply with localized message
|
||
|
message_type = "content"
|
||
|
if update.message.photo:
|
||
|
message_type = "photo"
|
||
|
elif update.message.document:
|
||
|
message_type = "document"
|
||
|
elif update.message.voice:
|
||
|
message_type = "voice"
|
||
|
elif update.message.video:
|
||
|
message_type = "video"
|
||
|
|
||
|
# Get localized content type name
|
||
|
language = get_user_language(update.message.from_user)
|
||
|
localized_type = MessageLocalizer.get_content_type_name(message_type, language)
|
||
|
|
||
|
reply_text = get_localized_message(
|
||
|
update.message.from_user,
|
||
|
'received_content',
|
||
|
message_type=localized_type
|
||
|
)
|
||
|
sent_message = await update.message.reply_text(reply_text)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Handled {message_type} from {update.message.from_user.username or update.message.from_user.id}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling non-text message: {e}")
|
||
|
|
||
|
async def _save_message(self, message, direction='incoming'):
|
||
|
"""Save message to database"""
|
||
|
try:
|
||
|
# Prepare message text
|
||
|
message_text = message.text or message.caption or ""
|
||
|
|
||
|
# Handle different message types
|
||
|
if message.photo:
|
||
|
message_text = f"[Photo] {message_text}"
|
||
|
elif message.document:
|
||
|
message_text = f"[Document: {message.document.file_name}] {message_text}"
|
||
|
elif message.voice:
|
||
|
message_text = f"[Voice message] {message_text}"
|
||
|
elif message.video:
|
||
|
message_text = f"[Video] {message_text}"
|
||
|
elif message.sticker:
|
||
|
message_text = f"[Sticker: {message.sticker.emoji or 'no emoji'}]"
|
||
|
|
||
|
# Convert message to dict for raw_data
|
||
|
raw_data = message.to_dict() if hasattr(message, 'to_dict') else {}
|
||
|
|
||
|
# Get user language and create database record
|
||
|
user_language = get_user_language(message.from_user)
|
||
|
telegram_message = await sync_to_async(TelegramMessage.objects.create)(
|
||
|
direction=direction,
|
||
|
telegram_user_id=message.from_user.id,
|
||
|
telegram_username=message.from_user.username or "",
|
||
|
telegram_first_name=message.from_user.first_name or "",
|
||
|
telegram_last_name=message.from_user.last_name or "",
|
||
|
chat_id=message.chat_id,
|
||
|
message_id=message.message_id,
|
||
|
message_text=message_text,
|
||
|
raw_data=raw_data,
|
||
|
user_language=user_language
|
||
|
)
|
||
|
|
||
|
logger.debug(f"Saved {direction} message from {message.from_user.id}")
|
||
|
return telegram_message
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error saving message: {e}")
|
||
|
return None
|
||
|
|
||
|
async def _save_outgoing_message(self, message, to_user):
|
||
|
"""Save outgoing message to database"""
|
||
|
try:
|
||
|
raw_data = message.to_dict() if hasattr(message, 'to_dict') else {}
|
||
|
user_language = get_user_language(to_user)
|
||
|
|
||
|
await sync_to_async(TelegramMessage.objects.create)(
|
||
|
direction='outgoing',
|
||
|
telegram_user_id=to_user.id,
|
||
|
telegram_username=to_user.username or "",
|
||
|
telegram_first_name=to_user.first_name or "",
|
||
|
telegram_last_name=to_user.last_name or "",
|
||
|
chat_id=message.chat_id,
|
||
|
message_id=message.message_id,
|
||
|
message_text=message.text,
|
||
|
raw_data=raw_data,
|
||
|
user_language=user_language
|
||
|
)
|
||
|
|
||
|
logger.debug(f"Saved outgoing message to {to_user.id}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error saving outgoing message: {e}")
|
||
|
|
||
|
async def _check_user_access(self, telegram_user):
|
||
|
"""Check if user exists or has pending request, or can be linked by username"""
|
||
|
from vpn.models import User
|
||
|
|
||
|
try:
|
||
|
# Check if user already exists by telegram_user_id
|
||
|
user = await sync_to_async(User.objects.filter(telegram_user_id=telegram_user.id).first)()
|
||
|
if user:
|
||
|
return {'action': 'existing_user', 'user': user}
|
||
|
|
||
|
# Check if user can be linked by telegram username
|
||
|
if telegram_user.username:
|
||
|
# Look for existing user with matching telegram_username (case-insensitive)
|
||
|
existing_user_by_username = await sync_to_async(
|
||
|
User.objects.filter(
|
||
|
telegram_username__iexact=telegram_user.username,
|
||
|
telegram_user_id__isnull=True # Not yet linked to Telegram
|
||
|
).first
|
||
|
)()
|
||
|
|
||
|
if existing_user_by_username:
|
||
|
# Link this telegram account to existing user
|
||
|
await self._link_telegram_to_user(existing_user_by_username, telegram_user)
|
||
|
return {'action': 'existing_user', 'user': existing_user_by_username}
|
||
|
|
||
|
# Check if this telegram_user_id was previously linked to a user but is now unlinked
|
||
|
# This handles the case where an admin "untied" the user from Telegram
|
||
|
unlinked_user = await sync_to_async(
|
||
|
User.objects.filter(
|
||
|
telegram_username__iexact=telegram_user.username if telegram_user.username else '',
|
||
|
telegram_user_id__isnull=True
|
||
|
).first
|
||
|
)() if telegram_user.username else None
|
||
|
|
||
|
# Check if user has pending access request
|
||
|
existing_request = await sync_to_async(
|
||
|
AccessRequest.objects.filter(telegram_user_id=telegram_user.id).first
|
||
|
)()
|
||
|
|
||
|
if existing_request:
|
||
|
if existing_request.approved:
|
||
|
# Check if there was an approved request but user was unlinked
|
||
|
# In this case, allow creating a new request (treat as new user)
|
||
|
if unlinked_user:
|
||
|
# Delete old request since user was unlinked
|
||
|
await sync_to_async(existing_request.delete)()
|
||
|
logger.info(f"Deleted old approved request for unlinked user @{telegram_user.username}")
|
||
|
return {'action': 'show_new_user_keyboard'}
|
||
|
else:
|
||
|
# Request approved but user not created yet (shouldn't happen but handle gracefully)
|
||
|
return {'action': 'show_new_user_keyboard'}
|
||
|
else:
|
||
|
# Check if user was unlinked after making the request
|
||
|
if unlinked_user:
|
||
|
# Delete old pending request and allow new one
|
||
|
await sync_to_async(existing_request.delete)()
|
||
|
logger.info(f"Deleted old pending request for unlinked user @{telegram_user.username}")
|
||
|
return {'action': 'show_new_user_keyboard'}
|
||
|
else:
|
||
|
# Request pending
|
||
|
return {'action': 'pending_request'}
|
||
|
|
||
|
# No user and no request - new user
|
||
|
return {'action': 'show_new_user_keyboard'}
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error checking user access: {e}")
|
||
|
# Default to new user keyboard if check fails
|
||
|
return {'action': 'show_new_user_keyboard'}
|
||
|
|
||
|
async def _handle_access_command(self, update: Update, user):
|
||
|
"""Handle Access button - show subscription groups keyboard"""
|
||
|
try:
|
||
|
# Import Xray models
|
||
|
from vpn.models_xray import UserSubscription
|
||
|
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||
|
|
||
|
# Get user's active subscription groups
|
||
|
subscriptions = await sync_to_async(
|
||
|
lambda: list(
|
||
|
UserSubscription.objects.filter(
|
||
|
user=user,
|
||
|
active=True,
|
||
|
subscription_group__is_active=True
|
||
|
).select_related('subscription_group')
|
||
|
)
|
||
|
)()
|
||
|
|
||
|
if subscriptions:
|
||
|
# Create keyboard with subscription groups using localized buttons
|
||
|
keyboard = []
|
||
|
|
||
|
# Get localized button texts
|
||
|
all_in_one_btn = get_localized_button(update.message.from_user, 'all_in_one')
|
||
|
group_prefix = get_localized_button(update.message.from_user, 'group_prefix')
|
||
|
back_btn = get_localized_button(update.message.from_user, 'back')
|
||
|
|
||
|
# Add All-in-one button first
|
||
|
keyboard.append([KeyboardButton(all_in_one_btn)])
|
||
|
|
||
|
# Add individual group buttons in 2 columns
|
||
|
group_buttons = []
|
||
|
for sub in subscriptions:
|
||
|
group_name = sub.subscription_group.name
|
||
|
group_buttons.append(KeyboardButton(f"{group_prefix}{group_name}"))
|
||
|
|
||
|
# Arrange buttons in 2 columns
|
||
|
for i in range(0, len(group_buttons), 2):
|
||
|
if i + 1 < len(group_buttons):
|
||
|
# Two buttons in a row
|
||
|
keyboard.append([group_buttons[i], group_buttons[i + 1]])
|
||
|
else:
|
||
|
# One button in the last row
|
||
|
keyboard.append([group_buttons[i]])
|
||
|
|
||
|
# Add back button
|
||
|
keyboard.append([KeyboardButton(back_btn)])
|
||
|
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
# Send message with keyboard using localized text
|
||
|
choose_text = get_localized_message(update.message.from_user, 'choose_subscription')
|
||
|
all_in_one_desc = get_localized_message(update.message.from_user, 'all_in_one_desc')
|
||
|
group_desc = get_localized_message(update.message.from_user, 'group_desc')
|
||
|
select_option = get_localized_message(update.message.from_user, 'select_option')
|
||
|
|
||
|
message_text = f"{choose_text}\n\n"
|
||
|
message_text += f"{all_in_one_desc}\n"
|
||
|
message_text += f"{group_desc}\n\n"
|
||
|
message_text += select_option
|
||
|
|
||
|
sent_message = await update.message.reply_text(
|
||
|
message_text,
|
||
|
reply_markup=reply_markup,
|
||
|
parse_mode='Markdown'
|
||
|
)
|
||
|
else:
|
||
|
# No active subscriptions - show main keyboard
|
||
|
access_button = get_localized_button(update.message.from_user, 'access')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(access_button)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions')
|
||
|
sent_message = await update.message.reply_text(
|
||
|
no_subs_msg,
|
||
|
reply_markup=reply_markup
|
||
|
)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Showed subscription groups keyboard to user {user.username}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling Access command: {e}")
|
||
|
error_msg = get_localized_message(update.message.from_user, 'error_loading_subscriptions')
|
||
|
await update.message.reply_text(error_msg)
|
||
|
|
||
|
async def _create_access_request(self, telegram_user, message, saved_message=None):
|
||
|
"""Create new access request"""
|
||
|
try:
|
||
|
# Check if request already exists (safety check)
|
||
|
existing = await sync_to_async(
|
||
|
AccessRequest.objects.filter(telegram_user_id=telegram_user.id).first
|
||
|
)()
|
||
|
|
||
|
if existing and not existing.approved:
|
||
|
logger.warning(f"Access request already exists for user {telegram_user.id}")
|
||
|
return existing
|
||
|
|
||
|
# Create new request with default username from Telegram and user language
|
||
|
default_username = telegram_user.username or ""
|
||
|
user_language = get_user_language(telegram_user)
|
||
|
request = await sync_to_async(AccessRequest.objects.create)(
|
||
|
telegram_user_id=telegram_user.id,
|
||
|
telegram_username=telegram_user.username or "",
|
||
|
telegram_first_name=telegram_user.first_name or "",
|
||
|
telegram_last_name=telegram_user.last_name or "",
|
||
|
message_text=message.text or message.caption or "[Non-text message]",
|
||
|
chat_id=message.chat_id,
|
||
|
first_message=saved_message,
|
||
|
desired_username=default_username,
|
||
|
user_language=user_language
|
||
|
)
|
||
|
|
||
|
logger.info(f"Created access request for user {telegram_user.username or telegram_user.id}")
|
||
|
return request
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error creating access request: {e}")
|
||
|
return None
|
||
|
|
||
|
async def _send_help_message(self, update: Update):
|
||
|
"""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')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(access_button)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
help_text = get_localized_message(update.message.from_user, 'help_text')
|
||
|
sent_message = await update.message.reply_text(
|
||
|
help_text,
|
||
|
reply_markup=reply_markup
|
||
|
)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error sending help message: {e}")
|
||
|
|
||
|
async def _handle_group_selection(self, update: Update, user, group_name):
|
||
|
"""Handle specific group selection"""
|
||
|
try:
|
||
|
from django.conf import settings
|
||
|
|
||
|
# Get the base URL for subscription links from settings
|
||
|
base_url = getattr(settings, 'EXTERNAL_ADDRESS', 'https://your-server.com')
|
||
|
|
||
|
# Parse base_url to ensure it has https:// scheme
|
||
|
if not base_url.startswith(('http://', 'https://')):
|
||
|
base_url = f"https://{base_url}"
|
||
|
|
||
|
# Generate group-specific subscription link
|
||
|
subscription_link = f"{base_url}/xray/{user.hash}?group={group_name}"
|
||
|
|
||
|
# Also generate user portal link
|
||
|
portal_link = f"{base_url}/u/{user.hash}"
|
||
|
|
||
|
# Build localized message
|
||
|
group_title = get_localized_message(update.message.from_user, 'group_subscription', group_name=group_name)
|
||
|
sub_link_label = get_localized_message(update.message.from_user, 'subscription_link')
|
||
|
portal_label = get_localized_message(update.message.from_user, 'web_portal')
|
||
|
tap_note = get_localized_message(update.message.from_user, 'tap_to_copy')
|
||
|
|
||
|
message_text = f"{group_title}\n\n"
|
||
|
message_text += f"{sub_link_label}\n"
|
||
|
message_text += f"`{subscription_link}`\n\n"
|
||
|
message_text += f"{portal_label}\n"
|
||
|
message_text += f"{portal_link}\n\n"
|
||
|
message_text += tap_note
|
||
|
|
||
|
# Create back navigation keyboard with only back button
|
||
|
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||
|
back_btn = get_localized_button(update.message.from_user, 'back')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(back_btn)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
sent_message = await update.message.reply_text(
|
||
|
message_text,
|
||
|
reply_markup=reply_markup,
|
||
|
parse_mode='Markdown'
|
||
|
)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Sent group {group_name} subscription to user {user.username}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling group selection: {e}")
|
||
|
error_msg = get_localized_message(update.message.from_user, 'error_loading_group')
|
||
|
await update.message.reply_text(error_msg)
|
||
|
|
||
|
async def _handle_all_in_one(self, update: Update, user):
|
||
|
"""Handle All-in-one selection"""
|
||
|
try:
|
||
|
# Import Xray models
|
||
|
from vpn.models_xray import UserSubscription, ServerInbound
|
||
|
from django.conf import settings
|
||
|
|
||
|
# Get user's active subscription groups
|
||
|
subscriptions = await sync_to_async(
|
||
|
lambda: list(
|
||
|
UserSubscription.objects.filter(
|
||
|
user=user,
|
||
|
active=True,
|
||
|
subscription_group__is_active=True
|
||
|
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||
|
)
|
||
|
)()
|
||
|
|
||
|
if subscriptions:
|
||
|
# Get the base URL for subscription links from settings
|
||
|
base_url = getattr(settings, 'EXTERNAL_ADDRESS', 'https://your-server.com')
|
||
|
|
||
|
# Parse base_url to ensure it has https:// scheme
|
||
|
if not base_url.startswith(('http://', 'https://')):
|
||
|
base_url = f"https://{base_url}"
|
||
|
|
||
|
# Generate universal subscription link
|
||
|
subscription_link = f"{base_url}/xray/{user.hash}"
|
||
|
|
||
|
# Also generate user portal link
|
||
|
portal_link = f"{base_url}/u/{user.hash}"
|
||
|
|
||
|
# Build message with detailed server/subscription list using localized text
|
||
|
all_in_one_title = get_localized_message(update.message.from_user, 'all_in_one_subscription')
|
||
|
access_includes = get_localized_message(update.message.from_user, 'your_access_includes')
|
||
|
message_text = f"{all_in_one_title}\n\n"
|
||
|
message_text += f"{access_includes}\n\n"
|
||
|
|
||
|
# Collect all servers and their subscriptions
|
||
|
servers_data = {}
|
||
|
|
||
|
for sub in subscriptions:
|
||
|
group = sub.subscription_group
|
||
|
group_name = group.name
|
||
|
|
||
|
# Get servers where this group's inbounds are deployed
|
||
|
deployed_servers = await sync_to_async(
|
||
|
lambda: list(
|
||
|
ServerInbound.objects.filter(
|
||
|
inbound__in=group.inbounds.all(),
|
||
|
active=True
|
||
|
).select_related('server', 'inbound').values_list(
|
||
|
'server__name', 'inbound__name', 'inbound__protocol'
|
||
|
).distinct()
|
||
|
)
|
||
|
)()
|
||
|
|
||
|
# Group by server
|
||
|
for server_name, inbound_name, protocol in deployed_servers:
|
||
|
if server_name not in servers_data:
|
||
|
servers_data[server_name] = []
|
||
|
servers_data[server_name].append({
|
||
|
'group_name': group_name,
|
||
|
'inbound_name': inbound_name,
|
||
|
'protocol': protocol.upper()
|
||
|
})
|
||
|
|
||
|
# Display servers and their subscriptions
|
||
|
for server_name, server_subscriptions in servers_data.items():
|
||
|
message_text += f"🔒 **{server_name}**\n"
|
||
|
|
||
|
for sub_data in server_subscriptions:
|
||
|
message_text += f" • {sub_data['group_name']} ({sub_data['protocol']})\n"
|
||
|
|
||
|
message_text += "\n"
|
||
|
|
||
|
# Add subscription link with localized labels
|
||
|
universal_link_label = get_localized_message(update.message.from_user, 'universal_subscription_link')
|
||
|
portal_label = get_localized_message(update.message.from_user, 'web_portal')
|
||
|
all_subs_note = get_localized_message(update.message.from_user, 'all_subscriptions_note')
|
||
|
|
||
|
message_text += f"{universal_link_label}\n"
|
||
|
message_text += f"`{subscription_link}`\n\n"
|
||
|
|
||
|
# Add portal link
|
||
|
message_text += f"{portal_label}\n"
|
||
|
message_text += f"{portal_link}\n\n"
|
||
|
|
||
|
message_text += all_subs_note
|
||
|
|
||
|
# Create back navigation keyboard with only back button
|
||
|
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||
|
back_btn = get_localized_button(update.message.from_user, 'back')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(back_btn)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
sent_message = await update.message.reply_text(
|
||
|
message_text,
|
||
|
reply_markup=reply_markup,
|
||
|
parse_mode='Markdown'
|
||
|
)
|
||
|
else:
|
||
|
# No active subscriptions
|
||
|
no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions')
|
||
|
sent_message = await update.message.reply_text(no_subs_msg)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Sent all-in-one subscription to user {user.username}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling all-in-one selection: {e}")
|
||
|
error_msg = get_localized_message(update.message.from_user, 'error_loading_subscriptions')
|
||
|
await update.message.reply_text(error_msg)
|
||
|
|
||
|
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')
|
||
|
keyboard = [
|
||
|
[KeyboardButton(access_btn)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
help_text = get_localized_message(update.message.from_user, 'help_text')
|
||
|
sent_message = await update.message.reply_text(
|
||
|
help_text,
|
||
|
reply_markup=reply_markup
|
||
|
)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Returned user {user.username} to main menu")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error handling back button: {e}")
|
||
|
await self._send_help_message(update)
|
||
|
|
||
|
async def _show_new_user_keyboard(self, update: Update):
|
||
|
"""Show keyboard with request access button for new users"""
|
||
|
try:
|
||
|
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||
|
|
||
|
# Get localized button and message
|
||
|
request_access_btn = get_localized_button(update.message.from_user, 'request_access')
|
||
|
welcome_msg = get_localized_message(update.message.from_user, 'new_user_welcome')
|
||
|
|
||
|
# Create keyboard with request access button
|
||
|
keyboard = [
|
||
|
[KeyboardButton(request_access_btn)],
|
||
|
]
|
||
|
reply_markup = ReplyKeyboardMarkup(
|
||
|
keyboard,
|
||
|
resize_keyboard=True,
|
||
|
one_time_keyboard=False
|
||
|
)
|
||
|
|
||
|
sent_message = await update.message.reply_text(
|
||
|
welcome_msg,
|
||
|
reply_markup=reply_markup
|
||
|
)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Showed new user keyboard to {update.message.from_user.username or update.message.from_user.id}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error showing new user keyboard: {e}")
|
||
|
|
||
|
async def _show_pending_request_message(self, update: Update, custom_message: str = None):
|
||
|
"""Show pending request message to users with pending access requests"""
|
||
|
try:
|
||
|
# Use custom message or default pending message
|
||
|
if custom_message:
|
||
|
message_text = custom_message
|
||
|
else:
|
||
|
message_text = get_localized_message(update.message.from_user, 'pending_request_msg')
|
||
|
|
||
|
sent_message = await update.message.reply_text(message_text)
|
||
|
|
||
|
# Save outgoing message
|
||
|
await self._save_outgoing_message(
|
||
|
sent_message,
|
||
|
update.message.from_user
|
||
|
)
|
||
|
|
||
|
logger.info(f"Showed pending request message to {update.message.from_user.username or update.message.from_user.id}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error showing pending request message: {e}")
|
||
|
|
||
|
async def _link_telegram_to_user(self, user, telegram_user):
|
||
|
"""Link Telegram account to existing VPN user"""
|
||
|
try:
|
||
|
user.telegram_user_id = telegram_user.id
|
||
|
user.telegram_username = telegram_user.username or ""
|
||
|
user.telegram_first_name = telegram_user.first_name or ""
|
||
|
user.telegram_last_name = telegram_user.last_name or ""
|
||
|
|
||
|
await sync_to_async(user.save)()
|
||
|
|
||
|
logger.info(f"Linked Telegram user @{telegram_user.username} (ID: {telegram_user.id}) to existing VPN user {user.username}")
|
||
|
|
||
|
except Exception as e:
|
||
|
logger.error(f"Error linking Telegram to user: {e}")
|
||
|
raise
|
||
|
|
||
|
def _sync_status_on_startup(self):
|
||
|
"""No database status to sync - only lock file matters"""
|
||
|
pass
|
||
|
|
||
|
def auto_start_if_enabled(self):
|
||
|
"""Auto-start bot if enabled in settings"""
|
||
|
try:
|
||
|
bot_settings = BotSettings.get_settings()
|
||
|
if bot_settings.enabled and bot_settings.bot_token and not self._running:
|
||
|
logger.info("Auto-starting bot (enabled in settings)")
|
||
|
self.start()
|
||
|
return True
|
||
|
else:
|
||
|
if not bot_settings.enabled:
|
||
|
logger.info("Bot auto-start skipped: disabled in settings")
|
||
|
elif not bot_settings.bot_token:
|
||
|
logger.info("Bot auto-start skipped: no token configured")
|
||
|
elif self._running:
|
||
|
logger.info("Bot auto-start skipped: already running")
|
||
|
except Exception as e:
|
||
|
# Don't log as error if it's just a lock conflict
|
||
|
if "could not acquire lock" in str(e).lower():
|
||
|
logger.info(f"Bot auto-start skipped: {e}")
|
||
|
else:
|
||
|
logger.error(f"Failed to auto-start bot: {e}")
|
||
|
return False
|
||
|
|
||
|
@property
|
||
|
def is_running(self):
|
||
|
"""Check if bot is running"""
|
||
|
return self._running and self._bot_thread and self._bot_thread.is_alive()
|