Files
OutFleet/telegram_bot/bot.py

1061 lines
45 KiB
Python
Raw Normal View History

2025-08-15 04:02:22 +03:00
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()