diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8de39d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +myenv/ +venv/ +.version + +# Рабочие данные и секреты бота +awg/files/* +!awg/files/config.json.example +awg/users/ +backup_*.zip diff --git a/README.md b/README.md index ccd9604..e1c2019 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,32 @@ # AmneziaVPN Telegram Bot -Телеграм-бот на Python для управления [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client). Этот бот позволяет легко управлять клиентами. +Телеграм-бот на Python для управления [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client). Этот бот позволяет легко управлять клиентами и продавать доступ через встроенные платежи Telegram. -Используется библиотека `aiogram` версии 2.25.2. -Протестировано на Ubuntu, версии 20.04, 22.04, 24.04. +Используется библиотека `aiogram` версии 3.x. +Требуется Python 3.10 или новее. Протестировано на Ubuntu 20.04, 22.04, 24.04. ## Оглавление - [Возможности](#возможности) - [Установка](#установка) +- [Настройка оплаты](#настройка-оплаты) +- [Конфигурация](#конфигурация) - [Запуск](#запуск) - [Заметки](#заметки) - [Поддержка](#поддержка) ## Возможности -- Добавление администраторов и модераторов для упралением VPN -- Добавление клиентов -- Удаление клиентов -- Получение информации об IP-адресе клиента (берется из Endpoint, используется API ресурса [ip-api.com](http://ip-api.com)) -- Создание ключа в формате `vpn://` при генерации нового клиента (так же, при получении конфигурации клиента), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client) +- Роли: администраторы, модераторы и обычные пользователи +- Добавление и удаление клиентов +- Создание ключа в формате `vpn://` при генерации нового клиента (и при получении конфигурации), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client) +- Статус клиента онлайн/офлайн по реальному времени последнего handshake +- Подписки на 1, 3, 6 и 12 месяцев с автоматическим отключением по истечении срока +- Продление подписки на период или до произвольной даты; остаток активной подписки не сгорает +- **Оплата подписки прямо в Telegram** (провайдер — ЮKassa), с чеками по 54-ФЗ при необходимости +- Промокоды двух видов: выдающие ключ бесплатно и дающие скидку при оплате +- Настраиваемые цены из интерфейса бота - Создание резервной копии -- Инструкции по работе с VPN ## Установка @@ -38,14 +43,65 @@ - Придумать уникальное имя пользователя для бота (например, `AmneziaWGManagerBot_bot`). Оно должно оканчиваться на `_bot`. - После создания бота BotFather отправит вам токен для доступа к API. Его запросит бот во время первоначальной инициализации. -4. Получите ваш Telegram ID с помощью [Get My ID](https://t.me/getmyid_bot), просто написав боту. +4. Получите ваш Telegram ID с помощью [Get My ID](https://t.me/getmyid_bot), просто написав боту. 5. Загрузите и запустите скрипт `install.sh`, с помощью которого будет автоматически установлен бот, со всеми зависимостями, в том числе, в качестве системной службы (автозапуск): ```bash - curl -O https://raw.githubusercontent.com/stevefoxru/amnezia-bot/main/install.sh && chmod +x install.sh && ./install.sh + curl -O https://raw.githubusercontent.com/viktor138irk/amnezia-bot/main/install.sh && chmod +x install.sh && ./install.sh ``` +## Настройка оплаты + +Оплата работает на встроенных платежах Telegram, поэтому боту **не нужен публичный +HTTPS-адрес, домен или сертификат** — Telegram сам подтверждает платёж. + +1. Откройте [BotFather](https://t.me/BotFather) → `/mybots` → ваш бот → **Payments**. +2. Выберите провайдера **ЮKassa** и подключите свой магазин. +3. BotFather выдаст **токен провайдера** (для тестов — с пометкой `TEST`). +4. Положите его в `awg/files/config.json` как `payment_provider_token` либо задайте + переменной окружения `AWG_PAYMENT_PROVIDER_TOKEN`. + +Если токен не задан, бот работает без оплаты: кнопка «Купить ключ» скрывается, ключи +выдаются только по промокодам. + +Минимальная сумма счёта в Telegram — 60 ₽, бот не даст выставить счёт меньше. + +Для чеков по 54-ФЗ включите блок `payment_receipt` в конфигурации — тогда бот будет +запрашивать email покупателя и передавать данные чека в ЮKassa. + +## Конфигурация + +Настройки лежат в `awg/files/config.json`, образец — в +[`awg/files/config.json.example`](awg/files/config.json.example). + +Любую настройку можно переопределить переменной окружения, чтобы не хранить секреты +в файле: + +| Переменная | Назначение | +| --- | --- | +| `AWG_BOT_HOME` | Каталог с данными бота (по умолчанию — каталог `awg/`) | +| `AWG_BOT_TOKEN` | Токен бота | +| `AWG_PAYMENT_PROVIDER_TOKEN` | Токен платёжного провайдера | +| `AWG_PAYMENT_CURRENCY` | Валюта счетов (по умолчанию `RUB`) | +| `AWG_ENDPOINT` | Адрес VPN-сервера | +| `AWG_DOCKER_CONTAINER` | Имя контейнера Amnezia | +| `AWG_WG_CONFIG_FILE` | Путь к конфигу WireGuard внутри контейнера | +| `AWG_LOG_LEVEL` | Уровень логирования (по умолчанию `INFO`) | + +Каталог установки больше не зашит в код: пути вычисляются относительно расположения +файлов бота. `install.sh` тоже понимает `AWG_BOT_HOME`, `AWG_PYTHON_BIN`, +`AWG_REPO_URL` и `AWG_REPO_BRANCH`. + +### Промокоды + +Формат добавления: `<код> <скидка%> <дней_действия> <макс_использований|none> <период|none>` + +- `FREE1M 100 30 10 1_month` — бесплатный ключ на месяц, 10 активаций, промокод живёт 30 дней +- `SALE20 20 30 none none` — скидка 20% при оплате, без ограничения по количеству + +Использование промокода засчитывается только после фактической выдачи ключа. + ## Запуск 1. Добавьте бота в Telegram и отправьте команду `/start` или `/help` для начала работы. @@ -54,11 +110,10 @@ Для обновления бота, необходимо запустить скрипт `install.sh`. В меню, необходимо выбрать пункт `Проверить обновления`. -При создании резервной копии, в архив добавляется директория connections (создается и содержит в себе логи подключений клиентов), conf, png, и сам конфигурационный файл. +При создании резервной копии, в архив добавляется директория `users` (конфигурации клиентов), `files` (настройки, промокоды, платежи, сроки подписок) и скрипты бота. + +Подписки проверяются раз в час: по истечении срока клиент удаляется с сервера, а его владелец получает уведомление. ## Поддержка -Поддержать разработчика можете следующими способами: - - -Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/stevefoxru/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику. +Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/viktor138irk/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику. diff --git a/awg/awg-decode.py b/awg/awg_decode.py similarity index 94% rename from awg/awg-decode.py rename to awg/awg_decode.py index 627aec7..ddc8acf 100644 --- a/awg/awg-decode.py +++ b/awg/awg_decode.py @@ -61,8 +61,7 @@ def process_conf_data(data): print(f"Resolved DNS '{address}' to IP '{resolved_ip}'", file=sys.stderr) return f"{prefix}{resolved_ip}:{port}{suffix}" else: - print(f"Error: Could not resolve DNS name '{address}'", file=sys.stderr) - sys.exit(1) + raise ValueError(f"Не удалось разрешить DNS-имя '{address}'") else: return full_line pattern = r'^(.*Endpoint\s*=\s*)([^\s:]+)(?::(\d+))(.*)$' @@ -107,7 +106,11 @@ def main(): print(f'Error reading file {args.input}: {e}') sys.exit(1) - processed_data = process_conf_data(data) + try: + processed_data = process_conf_data(data) + except ValueError as e: + print(f'Error: {e}', file=sys.stderr) + sys.exit(1) encoded_string = encode(processed_data) diff --git a/awg/bot_manager.py b/awg/bot_manager.py index ab318e2..cf8bac5 100644 --- a/awg/bot_manager.py +++ b/awg/bot_manager.py @@ -1,1074 +1,993 @@ -import db -import aiohttp -import logging +"""Telegram-бот управления AmneziaVPN (aiogram 3).""" import asyncio -import aiofiles +import html +import logging import os -import re -import json import sys -import uuid -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.dispatcher.middlewares import BaseMiddleware -from aiogram.utils import executor -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +import zipfile from datetime import datetime, timedelta -from apscheduler.schedulers.asyncio import AsyncIOScheduler -import pytz -import shutil -logging.basicConfig(level=logging.INFO) +import pytz +from aiogram import BaseMiddleware, Bot, Dispatcher, F, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.types import ( + CallbackQuery, FSInputFile, Message, PreCheckoutQuery, +) +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +import config +import db +import keyboards as kb +import payments +import settings as settings_module +import vpn + +logging.basicConfig( + level=os.environ.get('AWG_LOG_LEVEL', 'INFO'), + format='%(asctime)s %(levelname)s %(name)s: %(message)s', +) logger = logging.getLogger(__name__) -# Загрузка конфигурации -setting = db.get_config() -bot_token = setting.get('bot_token') -admin_ids = setting.get('admin_ids', []) -moderator_ids = setting.get('moderator_ids', []) -wg_config_file = setting.get('wg_config_file') -docker_container = setting.get('docker_container') -endpoint = setting.get('endpoint') -pricing = setting.get('pricing', { - '1_month': 1000.0, - '3_months': 2500.0, - '6_months': 4500.0, - '12_months': 8000.0 -}) +config.ensure_dirs() -if not all([bot_token, admin_ids, wg_config_file, docker_container, endpoint]): - logger.error("Некоторые обязательные настройки отсутствуют.") +try: + SETTINGS = settings_module.load() +except ValueError as e: + logger.error(str(e)) sys.exit(1) -admins = [int(admin_id) for admin_id in admin_ids] -moderators = [int(mod_id) for mod_id in moderator_ids] -bot = Bot(bot_token) -WG_CONFIG_FILE = wg_config_file -DOCKER_CONTAINER = docker_container -ENDPOINT = endpoint -PRICING = pricing +bot = Bot(SETTINGS.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) +dp = Dispatcher(storage=MemoryStorage()) -class AdminMessageDeletionMiddleware(BaseMiddleware): - async def on_process_message(self, message: types.Message, data: dict): - if message.from_user.id in admins and message.text.startswith('/'): - asyncio.create_task(delete_message_after_delay(message.chat.id, message.message_id)) - -dp = Dispatcher(bot) +# payment_router подключается первым: апдейт successful_payment приходит без +# текста и иначе его перехватил бы обработчик, ждущим ввода в FSM. +payment_router = Router() +router = Router() +dp.include_router(payment_router) +dp.include_router(router) scheduler = AsyncIOScheduler(timezone=pytz.utc) -scheduler.start() -dp.middleware.setup(AdminMessageDeletionMiddleware()) -# Главное меню -def get_main_menu_markup(user_id): - markup = InlineKeyboardMarkup(row_width=2) - if user_id in admins: - markup.add( - InlineKeyboardButton("➕ Добавить пользователя", callback_data="add_user"), - InlineKeyboardButton("📋 Список клиентов", callback_data="list_users") - ) - markup.add( - InlineKeyboardButton("🔑 Получить конфиг", callback_data="get_config"), - InlineKeyboardButton("🎟️ Управление промокодами", callback_data="manage_promocodes") - ) - markup.add( - InlineKeyboardButton("⚙️ Настройки", callback_data="settings"), - InlineKeyboardButton("🏠 Домой", callback_data="home") - ) - elif user_id in moderators: - markup.add( - InlineKeyboardButton("➕ Добавить пользователя", callback_data="add_user"), - InlineKeyboardButton("📋 Список клиентов", callback_data="list_users") - ) - markup.add( - InlineKeyboardButton("🔑 Получить конфиг", callback_data="get_config"), - InlineKeyboardButton("🏠 Домой", callback_data="home") - ) - else: - markup.add( - InlineKeyboardButton("🎟️ Получить ключ по промокоду", callback_data="use_promocode"), - InlineKeyboardButton("🏠 Домой", callback_data="home") - ) - return markup +ONLINE_THRESHOLD_SECONDS = 180 -# Меню покупки ключа -def get_buy_key_menu(user_id): - markup = InlineKeyboardMarkup(row_width=2) - markup.add(InlineKeyboardButton("🎟️ Ввести промокод", callback_data="use_promocode")) - markup.add(InlineKeyboardButton("🏠 Домой", callback_data="home")) - return markup -# Меню настроек -def get_settings_menu(): - markup = InlineKeyboardMarkup(row_width=2) - markup.add( - InlineKeyboardButton("💾 Создать бэкап", callback_data="create_backup"), - InlineKeyboardButton("👥 Список админов", callback_data="list_admins") - ) - markup.add( - InlineKeyboardButton("👤 Добавить админа", callback_data="add_admin"), - InlineKeyboardButton("💰 Настройки цен", callback_data="pricing_settings") - ) - markup.add(InlineKeyboardButton("⬅️ Назад", callback_data="home")) - return markup +class AdminCommandCleanupMiddleware(BaseMiddleware): + """Удаляет команды администраторов из чата, чтобы не засорять переписку.""" -# Меню настроек цен -def get_pricing_settings_menu(): - markup = InlineKeyboardMarkup(row_width=2) - periods = [ - ("1 месяц", "1_month"), - ("3 месяца", "3_months"), - ("6 месяцев", "6_months"), - ("12 месяцев", "12_months") - ] - for period_name, period_key in periods: - markup.add(InlineKeyboardButton( - f"{period_name} - ₽{PRICING.get(period_key, 0):.2f}", - callback_data=f"set_price_{period_key}" - )) - markup.add(InlineKeyboardButton("⬅️ Назад", callback_data="settings")) - return markup + async def __call__(self, handler, event: Message, data): + user = event.from_user + if user and event.text and event.text.startswith('/') and SETTINGS.is_admin(user.id): + asyncio.create_task(delete_message_later(event.chat.id, event.message_id)) + return await handler(event, data) -# Клавиатура для выбора периода продления -def get_renewal_period_keyboard(username): - markup = InlineKeyboardMarkup(row_width=2) - periods = [ - ("1 месяц", "1_month"), - ("3 месяца", "3_months"), - ("6 месяцев", "6_months"), - ("12 месяцев", "12_months"), - ("Кастомная дата", "custom_date") - ] - for period_name, period_key in periods: - markup.add(InlineKeyboardButton(period_name, callback_data=f"renew_period_{username}_{period_key}")) - markup.add(InlineKeyboardButton("Отмена", callback_data="home")) - return markup -user_main_messages = {} - -async def delete_message_after_delay(chat_id: int, message_id: int, delay: int = 2): +async def delete_message_later(chat_id, message_id, delay=2): await asyncio.sleep(delay) try: await bot.delete_message(chat_id, message_id) - except: + except Exception: pass -async def generate_vpn_key(conf_path: str) -> str: - process = await asyncio.create_subprocess_exec( - 'python3.11', '/root/amnezia-bot/awg/awg-decode.py', '--encode', conf_path, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + +dp.message.middleware(AdminCommandCleanupMiddleware()) + + +class Form(StatesGroup): + """Состояния диалогов, заменяют самодельный словарь состояний.""" + user_name = State() + admin_id = State() + promocode = State() + new_promocode = State() + price = State() + custom_date = State() + + +# --- Вспомогательные функции ------------------------------------------------ + +def esc(value): + """Экранирует текст для HTML-разметки.""" + return html.escape(str(value)) + + +def chat_id_of(callback): + """Чат, в котором показан экран. Для недоступного сообщения — личный чат.""" + message = callback.message + return message.chat.id if message is not None else callback.from_user.id + + +async def show(event, text, markup=None): + """Показывает экран: редактирует текущее сообщение или отправляет новое.""" + if not isinstance(event, CallbackQuery): + return await event.answer(text, reply_markup=markup) + + message = event.message + if message is not None: + try: + return await message.edit_text(text, reply_markup=markup) + except TelegramBadRequest as e: + if 'message is not modified' in str(e): + return message + # Сообщение не редактируется (документ, слишком старое) — шлём новое. + except AttributeError: + # InaccessibleMessage: содержимого нет, редактировать нечего. + pass + return await bot.send_message(chat_id_of(event), text, reply_markup=markup) + + +async def show_main_menu(event, state=None, text='Выберите действие:'): + if state is not None: + await state.clear() + user_id = event.from_user.id + return await show(event, text, kb.main_menu(user_id, SETTINGS)) + + +async def deny(callback, text='Нет прав.'): + await callback.answer(text, show_alert=True) + + +async def send_client_config(chat_id, username, header=None): + """Отправляет пользователю .conf и ссылку vpn:// для AmneziaVPN.""" + conf_path = vpn.client_conf_path(username) + if not conf_path.exists(): + await bot.send_message(chat_id, f'Конфигурация для {esc(username)} не найдена.') + return False + + caption = header or f'Конфигурация для {esc(username)}' + document = await bot.send_document( + chat_id, FSInputFile(conf_path, filename=f'{username}.conf'), caption=caption, ) - stdout, stderr = await process.communicate() - if process.returncode == 0 and stdout.decode().startswith('vpn://'): - return stdout.decode().strip() + try: + await bot.pin_chat_message(chat_id, document.message_id, disable_notification=True) + except TelegramBadRequest: + pass + + vpn_key = await vpn.generate_vpn_key(conf_path) + if vpn_key: + # Ключ уходит отдельным сообщением: он длинный и не всегда влезает в подпись. + await bot.send_message( + chat_id, + 'Ключ для приложения AmneziaVPN ' + '(Google Play, ' + 'GitHub):\n' + f'
{esc(vpn_key)}',
+ disable_web_page_preview=True,
+ )
else:
- logger.error(f"Ошибка генерации vpn://: {stderr.decode()}")
- return ""
+ await bot.send_message(
+ chat_id,
+ 'Не удалось сформировать ссылку vpn:// — используйте приложенный файл .conf.',
+ )
+ return True
-async def issue_vpn_key(user_id: int, period: str) -> bool:
- username = f"user_{user_id}_{uuid.uuid4().hex[:8]}"
- success = db.root_add(username, ipv6=False)
- if success:
- months = {'1_month': 1, '3_months': 3, '6_months': 6, '12_months': 12}.get(period, 1)
- expiration = datetime.now(pytz.utc) + timedelta(days=30 * months)
- db.set_user_expiration(username, expiration, "Неограниченно")
- db.set_user_telegram_id(username, user_id)
- conf_path = os.path.join('users', username, f'{username}.conf')
- if os.path.exists(conf_path):
- vpn_key = await generate_vpn_key(conf_path)
- caption = f"Ваш VPN ключ ({period.replace('_', ' ')}):\nAmneziaVPN:\n[Google Play](https://play.google.com/store/apps/details?id=org.amnezia.vpn&hl=ru)\n[GitHub](https://github.com/amnezia-vpn/amnezia-client)\n```\n{vpn_key}\n```"
- with open(conf_path, 'rb') as config:
- config_message = await bot.send_document(user_id, config, caption=caption, parse_mode="Markdown")
- await bot.pin_chat_message(user_id, config_message.message_id, disable_notification=True)
- return True
- return False
-@dp.message_handler(commands=['start', 'help'])
-async def start_command_handler(message: types.Message):
- user_id = message.from_user.id
- if user_id in user_main_messages:
+async def notify_admins(text):
+ """Сообщает всем администраторам о ситуации, требующей вмешательства."""
+ for admin_id in SETTINGS.admin_ids:
try:
- await bot.delete_message(
- chat_id=user_main_messages[user_id]['chat_id'],
- message_id=user_main_messages[user_id]['message_id']
- )
- except:
- pass
- sent_message = await message.answer("Выберите действие:", reply_markup=get_main_menu_markup(user_id))
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
+ await bot.send_message(admin_id, text)
+ except Exception as e:
+ logger.warning(f"Не удалось уведомить админа {admin_id}: {e}")
-@dp.message_handler(commands=['add_admin'])
-async def add_admin_command(message: types.Message):
- if message.from_user.id not in admins:
- await message.answer("У вас нет прав.")
+
+def format_expiration(expiration):
+ return expiration.strftime('%d.%m.%Y %H:%M UTC') if expiration else 'не установлен'
+
+
+# --- Команды ----------------------------------------------------------------
+
+@router.message(Command('start', 'help'))
+async def start_command(message: Message, state: FSMContext):
+ await state.clear()
+ await message.answer('Выберите действие:', reply_markup=kb.main_menu(message.from_user.id, SETTINGS))
+
+
+@router.message(Command('add_admin'))
+async def add_admin_command(message: Message):
+ if not SETTINGS.is_admin(message.from_user.id):
+ await message.answer('У вас нет прав.')
return
- try:
- new_admin_id = int(message.text.split()[1])
- if new_admin_id not in admins:
- db.add_admin(new_admin_id)
- admins.append(new_admin_id)
- await message.answer(f"Админ {new_admin_id} добавлен.")
- await bot.send_message(new_admin_id, "Вы назначены администратором!")
- except:
- await message.answer("Формат: /add_admin {esc(SETTINGS.docker_container)}.'
)
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text=text,
- parse_mode="Markdown",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
+ await show_main_menu(message, state)
-@dp.callback_query_handler(lambda c: c.data.startswith('renew_user_'))
-async def renew_user_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
- return
- username = callback_query.data.split('renew_user_')[1]
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите период продления или укажите дату:",
- reply_markup=get_renewal_period_keyboard(username)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
-
-@dp.callback_query_handler(lambda c: c.data.startswith('renew_period_'))
-async def renew_period_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
- return
- try:
- parts = callback_query.data.split('renew_period_')[1].split('_', 1)
- username = parts[0]
- period = parts[1] if len(parts) > 1 else 'custom_date'
- if period == 'custom_date':
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Введите дату продления в формате ДД-ММ-ГГГГ (например, 31-12-2025):",
- reply_markup=InlineKeyboardMarkup().add(InlineKeyboardButton("Отмена", callback_data="home"))
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': f'waiting_for_custom_date_{username}'
- }
- else:
- months = {'1_month': 1, '3_months': 3, '6_months': 6, '12_months': 12}[period]
- expiration = datetime.now(pytz.utc) + timedelta(days=30 * months)
- db.set_user_expiration(username, expiration, "Неограниченно")
- text = f"Подписка для {username} продлена до {expiration.strftime('%Y-%m-%d %H:%M UTC')}."
- logger.info(f"Подписка для {username} продлена на {period} до {expiration}.")
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text=text,
- parse_mode="Markdown",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
- except Exception as e:
- text = f"Ошибка при продлении: {str(e)}"
- logger.error(f"Ошибка при продлении подписки для {username}: {str(e)}")
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text=text,
- parse_mode="Markdown",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
-
-@dp.callback_query_handler(lambda c: c.data == "home")
-async def return_home(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите действие:",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
-
-@dp.callback_query_handler(lambda c: c.data == "get_config")
-async def list_users_for_config(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins and user_id not in moderators:
- await callback_query.answer("Нет прав.", show_alert=True)
+
+@router.callback_query(F.data == 'list_users')
+async def list_users(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_staff(callback.from_user.id):
+ await deny(callback)
return
+ await state.clear()
clients = db.get_client_list()
if not clients:
- await callback_query.answer("Список пуст.", show_alert=True)
+ await show(callback, 'Список клиентов пуст.', kb.home_only())
+ await callback.answer()
return
- keyboard = InlineKeyboardMarkup(row_width=2)
- for client in clients:
- keyboard.insert(InlineKeyboardButton(client[0], callback_data=f"send_config_{client[0]}"))
- keyboard.add(InlineKeyboardButton("🏠 Домой", callback_data="home"))
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите пользователя:",
- reply_markup=keyboard
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
+ active = await vpn.active_clients(SETTINGS)
+ now = datetime.now(pytz.utc)
+ rows = []
+ for username, _ in clients:
+ handshake = active.get(username)
+ online = handshake and (now - handshake).total_seconds() <= ONLINE_THRESHOLD_SECONDS
+ rows.append((username, '🟢' if online else '❌'))
-@dp.callback_query_handler(lambda c: c.data.startswith('send_config_'))
-async def send_user_config(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins and user_id not in moderators:
- await callback_query.answer("Нет прав.", show_alert=True)
+ await show(callback, 'Выберите пользователя:', kb.client_list(rows, 'client'))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('client:'))
+async def client_selected(callback: CallbackQuery):
+ if not SETTINGS.is_staff(callback.from_user.id):
+ await deny(callback)
return
- username = callback_query.data.split('send_config_')[1]
- conf_path = os.path.join('users', username, f'{username}.conf')
- if os.path.exists(conf_path):
- vpn_key = await generate_vpn_key(conf_path)
- caption = f"Конфигурация для {username}:\nAmneziaVPN:\n[Google Play](https://play.google.com/store/apps/details?id=org.amnezia.vpn&hl=ru)\n[GitHub](https://github.com/amnezia-vpn/amnezia-client)\n```\n{vpn_key}\n```"
- with open(conf_path, 'rb') as config:
- config_message = await bot.send_document(user_id, config, caption=caption, parse_mode="Markdown")
- await bot.pin_chat_message(user_id, config_message.message_id, disable_notification=True)
+ username = callback.data.split(':', 1)[1]
+ if not vpn.client_conf_path(username).exists():
+ await callback.answer('Пользователь не найден.', show_alert=True)
+ return
+
+ active = await vpn.active_clients(SETTINGS)
+ handshake = active.get(username)
+ now = datetime.now(pytz.utc)
+ if handshake and (now - handshake).total_seconds() <= ONLINE_THRESHOLD_SECONDS:
+ status = '🟢 Онлайн'
+ elif handshake:
+ status = f'🔴 Офлайн (последний handshake {handshake.strftime("%d.%m.%Y %H:%M UTC")})'
else:
- await bot.send_message(user_id, f"Конфигурация для **{username}** не найдена.", parse_mode="Markdown")
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите действие:",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
+ status = '🔴 Не подключался'
-@dp.callback_query_handler(lambda c: c.data == "create_backup")
-async def create_backup_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
+ owner = db.get_user_telegram_id(username)
+ text = (
+ f'📧 Имя: {esc(username)}\n'
+ f'🌐 Статус: {esc(status)}\n'
+ f'⏰ Срок действия: {esc(format_expiration(db.get_user_expiration(username)))}\n'
+ f'👤 Владелец: {esc(owner) if owner else "не привязан"}'
+ )
+ await show(callback, text, kb.client_actions(username))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('delete:'))
+async def delete_client(callback: CallbackQuery):
+ if not SETTINGS.is_staff(callback.from_user.id):
+ await deny(callback)
return
- backup_filename = f"backup_{datetime.now().strftime('%Y-%m-%d')}.zip"
- with zipfile.ZipFile(backup_filename, 'w') as zipf:
- for file in ['awg-decode.py', 'newclient.sh', 'removeclient.sh']:
- if os.path.exists(file):
- zipf.write(file)
- for root, _, files in os.walk('files'):
- for file in files:
- zipf.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), os.getcwd()))
- for root, _, files in os.walk('users'):
- for file in files:
- zipf.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), os.getcwd()))
- with open(backup_filename, 'rb') as f:
- await bot.send_document(user_id, f, caption=backup_filename)
- os.remove(backup_filename)
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите действие:",
- reply_markup=get_main_menu_markup(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
+ username = callback.data.split(':', 1)[1]
+ await callback.answer('Удаляю...')
+ if await vpn.delete_client(username, SETTINGS):
+ text = f'Пользователь {esc(username)} удалён.'
+ logger.info(f"Пользователь {username} удалён.")
+ else:
+ text = f'Не удалось удалить {esc(username)}. Проверьте логи.'
+ await show(callback, text, kb.main_menu(callback.from_user.id, SETTINGS))
-@dp.callback_query_handler(lambda c: c.data == "buy_key")
-async def buy_key_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
+
+@router.callback_query(F.data.startswith('sendconf:'))
+async def send_config_to_staff(callback: CallbackQuery):
+ if not SETTINGS.is_staff(callback.from_user.id):
+ await deny(callback)
+ return
+ username = callback.data.split(':', 1)[1]
+ await callback.answer()
+ await send_client_config(chat_id_of(callback), username)
+
+
+@router.callback_query(F.data == 'get_config')
+async def list_users_for_config(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_staff(callback.from_user.id):
+ await deny(callback)
+ return
+ await state.clear()
+ clients = db.get_client_list()
+ if not clients:
+ await show(callback, 'Список клиентов пуст.', kb.home_only())
+ await callback.answer()
+ return
+ rows = [(username, '') for username, _ in clients]
+ await show(callback, 'Выберите пользователя:', kb.client_list(rows, 'sendconf'))
+ await callback.answer()
+
+
+# --- Продление подписки -----------------------------------------------------
+
+@router.callback_query(F.data.startswith('renew:'))
+async def renew_prompt(callback: CallbackQuery):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ username = callback.data.split(':', 1)[1]
+ await show(callback, 'Выберите период продления или укажите дату:', kb.renewal_periods(username))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('renewp:'))
+async def renew_period(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ _, username, period = callback.data.split(':', 2)
+
+ if period == 'custom':
+ await state.set_state(Form.custom_date)
+ await state.update_data(username=username)
+ await show(
+ callback,
+ 'Введите дату окончания подписки в формате ДД-ММ-ГГГГ (например, 31-12-2026):',
+ kb.single('Отмена', f'client:{username}'),
)
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Меню получения ключа:",
- reply_markup=get_buy_key_menu(user_id)
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
+ await callback.answer()
+ return
-@dp.callback_query_handler(lambda c: c.data == "use_promocode")
-async def use_promocode_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
+ if period not in config.PERIODS:
+ await callback.answer('Неизвестный период.', show_alert=True)
+ return
+
+ expiration = vpn.extend_subscription(username, period)
+ logger.info(f"Подписка {username} продлена на {period} до {expiration}.")
+ await show(
+ callback,
+ f'Подписка для {esc(username)} продлена до {esc(format_expiration(expiration))}.',
+ kb.main_menu(callback.from_user.id, SETTINGS),
+ )
+ await callback.answer()
+
+
+@router.message(Form.custom_date)
+async def set_custom_date(message: Message, state: FSMContext):
+ if not SETTINGS.is_admin(message.from_user.id):
+ await state.clear()
+ return
+ data = await state.get_data()
+ username = data.get('username')
try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Введите промокод для получения ключа:",
- reply_markup=InlineKeyboardMarkup().add(InlineKeyboardButton("🏠 Домой", callback_data="home"))
+ expiration = datetime.strptime((message.text or '').strip(), '%d-%m-%Y').replace(tzinfo=pytz.utc)
+ except ValueError:
+ await message.reply('Введите дату в формате ДД-ММ-ГГГГ (например, 31-12-2026).')
+ return
+ if expiration < datetime.now(pytz.utc):
+ await message.reply('Дата должна быть в будущем.')
+ return
+ db.set_user_expiration(username, expiration)
+ await message.reply(
+ f'Подписка для {esc(username)} продлена до {esc(format_expiration(expiration))}.'
)
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': 'waiting_for_promocode'
- }
- await callback_query.answer()
+ await show_main_menu(message, state)
-@dp.callback_query_handler(lambda c: c.data == "manage_promocodes")
-async def manage_promocodes_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
+
+# --- Администраторы ---------------------------------------------------------
+
+@router.callback_query(F.data == 'list_admins')
+async def list_admins(callback: CallbackQuery):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ text = 'Администраторы:\n' + '\n'.join(f'• {admin_id}' for admin_id in sorted(SETTINGS.admin_ids))
+ await show(callback, text, kb.admin_list(SETTINGS.admin_ids))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('rmadmin:'))
+async def remove_admin(callback: CallbackQuery):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ admin_id = int(callback.data.split(':', 1)[1])
+ if admin_id not in SETTINGS.admin_ids:
+ await callback.answer('Администратор не найден.', show_alert=True)
+ return
+ if len(SETTINGS.admin_ids) <= 1:
+ await callback.answer('Нельзя удалить последнего администратора.', show_alert=True)
+ return
+ db.remove_admin(admin_id)
+ SETTINGS.admin_ids.discard(admin_id)
+ try:
+ await bot.send_message(admin_id, 'Вы удалены из администраторов.')
+ except Exception as e:
+ logger.warning(f"Не удалось уведомить снятого админа {admin_id}: {e}")
+ await callback.answer('Администратор удалён.')
+ await list_admins(callback)
+
+
+@router.callback_query(F.data == 'add_admin')
+async def prompt_for_admin_id(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ await state.set_state(Form.admin_id)
+ await show(callback, 'Введите Telegram ID нового администратора:', kb.single('Отмена', 'settings'))
+ await callback.answer()
+
+
+@router.message(Form.admin_id)
+async def add_admin_from_state(message: Message, state: FSMContext):
+ if not SETTINGS.is_admin(message.from_user.id):
+ await state.clear()
+ return
+ raw = (message.text or '').strip()
+ if not raw.lstrip('-').isdigit():
+ await message.reply('Введите корректный числовой Telegram ID.')
+ return
+ new_admin_id = int(raw)
+ if new_admin_id in SETTINGS.admin_ids:
+ await message.reply('Этот пользователь уже администратор.')
+ else:
+ db.add_admin(new_admin_id)
+ SETTINGS.admin_ids.add(new_admin_id)
+ await message.reply(f'Админ {new_admin_id} добавлен.')
+ try:
+ await bot.send_message(new_admin_id, 'Вы назначены администратором!')
+ except Exception as e:
+ logger.warning(f"Не удалось уведомить нового админа {new_admin_id}: {e}")
+ await show_main_menu(message, state)
+
+
+# --- Цены -------------------------------------------------------------------
+
+@router.callback_query(F.data == 'pricing_settings')
+async def pricing_settings(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ await state.clear()
+ await show(callback, 'Настройки цен — выберите период:', kb.pricing_settings_menu(SETTINGS))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('setprice:'))
+async def set_price_prompt(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ period = callback.data.split(':', 1)[1]
+ if period not in config.PERIODS:
+ await callback.answer('Неизвестный период.', show_alert=True)
+ return
+ await state.set_state(Form.price)
+ await state.update_data(period=period)
+ await show(
+ callback,
+ f'Введите новую цену для периода «{esc(config.period_label(period))}» в рублях (например, 1000):',
+ kb.single('⬅️ Назад', 'pricing_settings'),
+ )
+ await callback.answer()
+
+
+@router.message(Form.price)
+async def set_price(message: Message, state: FSMContext):
+ if not SETTINGS.is_admin(message.from_user.id):
+ await state.clear()
+ return
+ data = await state.get_data()
+ period = data.get('period')
+ try:
+ price = float((message.text or '').strip().replace(',', '.'))
+ except ValueError:
+ await message.reply('Введите корректное число (например, 1000).')
+ return
+ if price <= 0:
+ await message.reply('Цена должна быть больше нуля.')
+ return
+ if SETTINGS.currency == 'RUB' and price < settings_module.MIN_INVOICE_RUB:
+ await message.reply(
+ f'Telegram не принимает счета меньше {settings_module.MIN_INVOICE_RUB:g} ₽. '
+ 'Укажите большую сумму.'
+ )
+ return
+
+ db.set_pricing(period, price)
+ SETTINGS.pricing[period] = price
+ await message.reply(f'Цена для «{esc(config.period_label(period))}» обновлена: {price:.2f} ₽')
+ await state.clear()
+ await message.answer('Настройки цен — выберите период:', reply_markup=kb.pricing_settings_menu(SETTINGS))
+
+
+# --- Промокоды --------------------------------------------------------------
+
+@router.callback_query(F.data == 'manage_promocodes')
+async def manage_promocodes(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ await state.clear()
+ promocodes = db.get_promocodes()
+ if promocodes:
+ lines = []
+ for code, info in promocodes.items():
+ expires = info['expires_at'].strftime('%d.%m.%Y') if info['expires_at'] else 'бессрочно'
+ lines.append(
+ f"{esc(code)} — скидка {info['discount']:g}%, "
+ f"использован {info['uses']}/{info['max_uses'] or '∞'}, до {expires}, "
+ f"период: {esc(config.period_label(info['subscription_period'])) if info['subscription_period'] else 'нет'}"
+ )
+ text = 'Промокоды:\n' + '\n'.join(lines)
+ else:
+ text = 'Промокоды отсутствуют.'
+ await show(callback, text, kb.promocodes_menu())
+ await callback.answer()
+
+
+@router.callback_query(F.data == 'add_promocode')
+async def add_promocode_prompt(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ await state.set_state(Form.new_promocode)
+ await show(
+ callback,
+ 'Введите промокод в формате:\n'
+ '<код> <скидка%> <дней_действия> <макс_использований|none> <период|none>\n\n'
+ 'Период выдаёт ключ бесплатно, скидка — уменьшает цену при оплате.\n'
+ 'Примеры:\n'
+ 'FREE1M 100 30 10 1_month — бесплатный ключ на месяц, 10 активаций\n'
+ 'SALE20 20 30 none none — скидка 20% при оплате, без лимита',
+ kb.single('⬅️ Назад', 'manage_promocodes'),
+ )
+ await callback.answer()
+
+
+@router.message(Form.new_promocode)
+async def add_promocode(message: Message, state: FSMContext):
+ if not SETTINGS.is_admin(message.from_user.id):
+ await state.clear()
+ return
+ hint = (
+ 'Формат: <код> <скидка%> <дней> <макс_использований|none> <период|none>\n'
+ 'Пример: SALE20 20 30 none none'
+ )
+ parts = (message.text or '').strip().split()
+ if len(parts) != 5:
+ await message.reply(hint)
+ return
+ code, raw_discount, raw_days, raw_max_uses, period = parts
+ try:
+ discount = float(raw_discount.replace(',', '.'))
+ days_valid = int(raw_days)
+ except ValueError:
+ await message.reply(hint)
+ return
+ if not 0 <= discount <= 100:
+ await message.reply('Скидка должна быть от 0 до 100.')
+ return
+ if raw_max_uses.lower() == 'none':
+ max_uses = None
+ elif raw_max_uses.isdigit() and int(raw_max_uses) > 0:
+ max_uses = int(raw_max_uses)
+ else:
+ await message.reply('Максимум использований — положительное число или none.')
+ return
+ if period.lower() == 'none':
+ period = None
+ elif period not in config.PERIODS:
+ await message.reply(f"Период должен быть одним из: {', '.join(config.PERIODS)} или none.")
+ return
+ if period is None and discount <= 0:
+ await message.reply('Промокод без периода и без скидки бесполезен.')
+ return
+
+ expires_at = datetime.now(pytz.utc) + timedelta(days=days_valid) if days_valid > 0 else None
+
+ if db.add_promocode(code, discount, expires_at, max_uses, period):
+ await message.reply(
+ f'Промокод {esc(code)} добавлен: скидка {discount:g}%, '
+ f"действует {days_valid if days_valid > 0 else '∞'} дней, "
+ f"использований: {max_uses or '∞'}, "
+ f"период: {esc(config.period_label(period)) if period else 'нет'}."
+ )
+ else:
+ await message.reply('Такой промокод уже существует.')
+ await state.clear()
+ await message.answer('Управление промокодами:', reply_markup=kb.promocodes_menu())
+
+
+@router.callback_query(F.data == 'delete_promocode')
+async def delete_promocode_menu(callback: CallbackQuery):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
return
promocodes = db.get_promocodes()
- text = "Промокоды:\n" + "\n".join(
- f"{code}: {info['discount']}% (использовано {info['uses']}/{info['max_uses'] or '∞'}, до {info['expires_at'].strftime('%Y-%m-%d %H:%M UTC') if info['expires_at'] else 'неограничено'}, период подписки: {info['subscription_period'] or 'нет'})"
- for code, info in promocodes.items()
- ) if promocodes else "Промокоды отсутствуют."
- keyboard = InlineKeyboardMarkup(row_width=2).add(
- InlineKeyboardButton("➕ Добавить промокод", callback_data="add_promocode"),
- InlineKeyboardButton("🗑️ Удалить промокод", callback_data="delete_promocode"),
- InlineKeyboardButton("🏠 Домой", callback_data="home")
- )
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text=text,
- reply_markup=keyboard
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
-
-@dp.callback_query_handler(lambda c: c.data == "add_promocode")
-async def add_promocode_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
+ if not promocodes:
+ await callback.answer('Промокоды отсутствуют.', show_alert=True)
return
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Введите промокод в формате: <код> <скидка%> <дней_действия> <макс_использований|none> <период_подписки|none>\nПример: PROMO1 10 30 none 1_month",
- reply_markup=InlineKeyboardMarkup().add(InlineKeyboardButton("🏠 Домой", callback_data="home"))
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': 'waiting_for_new_promocode'
- }
- await callback_query.answer()
+ await show(callback, 'Выберите промокод для удаления:', kb.promocode_list(promocodes))
+ await callback.answer()
-@dp.callback_query_handler(lambda c: c.data == "delete_promocode")
-async def delete_promocode_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
- return
- promocodes = db.get_promocodes()
- keyboard = InlineKeyboardMarkup(row_width=2)
- for code in promocodes:
- keyboard.insert(InlineKeyboardButton(f"🗑️ {code}", callback_data=f"remove_promocode_{code}"))
- keyboard.add(InlineKeyboardButton("🏠 Домой", callback_data="home"))
- try:
- await bot.delete_message(
- chat_id=callback_query.message.chat.id,
- message_id=callback_query.message.message_id
- )
- except:
- pass
- sent_message = await bot.send_message(
- chat_id=callback_query.message.chat.id,
- text="Выберите промокод для удаления:",
- reply_markup=keyboard
- )
- user_main_messages[user_id] = {
- 'chat_id': sent_message.chat.id,
- 'message_id': sent_message.message_id,
- 'state': None
- }
- await callback_query.answer()
-@dp.callback_query_handler(lambda c: c.data.startswith('remove_promocode_'))
-async def remove_promocode_callback(callback_query: types.CallbackQuery):
- user_id = callback_query.from_user.id
- if user_id not in admins:
- await callback_query.answer("Нет прав.", show_alert=True)
+@router.callback_query(F.data.startswith('rmpromo:'))
+async def remove_promocode(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
return
- code = callback_query.data.split('remove_promocode_')[1]
+ code = callback.data.split(':', 1)[1]
if db.remove_promocode(code):
- await callback_query.answer(f"Промокод {code} удалён.", show_alert=True)
+ await callback.answer(f'Промокод {code} удалён.')
else:
- await callback_query.answer(f"Промокод {code} не найден.", show_alert=True)
- await manage_promocodes_callback(callback_query)
+ await callback.answer(f'Промокод {code} не найден.', show_alert=True)
+ await manage_promocodes(callback, state)
+
+
+@router.callback_query(F.data == 'use_promocode')
+async def use_promocode_prompt(callback: CallbackQuery, state: FSMContext):
+ await state.set_state(Form.promocode)
+ await show(callback, 'Введите промокод:', kb.home_only())
+ await callback.answer()
+
+
+@router.message(Form.promocode)
+async def apply_promocode(message: Message, state: FSMContext):
+ code = (message.text or '').strip()
+ promo = db.validate_promocode(code)
+ if not promo:
+ await message.reply('Неверный или истёкший промокод.')
+ await show_main_menu(message, state)
+ return
+
+ period = promo['subscription_period']
+ if period:
+ # Промокод сразу выдаёт ключ — оплата не нужна.
+ status = await message.answer('Создаю ключ, это займёт несколько секунд...')
+ username = await vpn.issue_subscription(message.from_user.id, period, SETTINGS)
+ await status.delete()
+ if username:
+ db.consume_promocode(code)
+ await send_client_config(
+ message.chat.id, username,
+ header=f'Ваш VPN-ключ на {esc(config.period_label(period))}',
+ )
+ await message.answer(
+ f'Промокод активирован. Подписка действует до '
+ f'{esc(format_expiration(db.get_user_expiration(username)))}.'
+ )
+ else:
+ await message.answer('Не удалось выдать ключ. Обратитесь к администратору.')
+ await notify_admins(
+ f'❗ Не удалось выдать ключ по промокоду {code} пользователю {message.from_user.id}.'
+ )
+ await show_main_menu(message, state)
+ return
+
+ # Промокод только со скидкой — запоминаем и показываем цены со скидкой.
+ if not SETTINGS.payments_enabled:
+ await message.reply('Оплата сейчас недоступна, а этот промокод даёт только скидку.')
+ await show_main_menu(message, state)
+ return
+
+ await state.clear()
+ await state.update_data(promocode=code, discount=promo['discount'])
+ await message.answer(
+ f"Промокод {esc(code)} принят: скидка {promo['discount']:g}%.\n"
+ 'Выберите период подписки:',
+ reply_markup=kb.buy_menu(SETTINGS, promo['discount']),
+ )
+
+
+# --- Оплата (встроенные платежи Telegram) -----------------------------------
+
+@router.callback_query(F.data == 'buy_key')
+async def buy_key_menu(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.payments_enabled:
+ await callback.answer('Оплата не настроена. Используйте промокод.', show_alert=True)
+ return
+ data = await state.get_data()
+ discount = data.get('discount', 0.0)
+ text = 'Выберите период подписки:'
+ if discount:
+ text = f"Скидка по промокоду {esc(data.get('promocode'))}: {discount:g}%\n{text}"
+ await show(callback, text, kb.buy_menu(SETTINGS, discount))
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith('buy:'))
+async def send_invoice(callback: CallbackQuery, state: FSMContext):
+ if not SETTINGS.payments_enabled:
+ await callback.answer('Оплата не настроена.', show_alert=True)
+ return
+ period = callback.data.split(':', 1)[1]
+ if period not in config.PERIODS:
+ await callback.answer('Неизвестный период.', show_alert=True)
+ return
+
+ data = await state.get_data()
+ promocode = data.get('promocode')
+ discount = data.get('discount', 0.0)
+ # Промокод мог быть израсходован, пока пользователь выбирал период.
+ if promocode and not db.validate_promocode(promocode):
+ promocode, discount = None, 0.0
+ await bot.send_message(chat_id_of(callback), 'Промокод больше не действует, счёт выставлен без скидки.')
+
+ price = payments.final_price(SETTINGS, period, discount)
+ if price <= 0:
+ # Скидка 100% — счёт выставлять не нужно, выдаём ключ сразу.
+ await callback.answer()
+ await grant_paid_subscription(
+ callback.from_user.id, chat_id_of(callback), period, promocode,
+ amount=0, currency=SETTINGS.currency, charge_id=f'promo:{promocode}:{callback.from_user.id}',
+ )
+ await state.clear()
+ return
+
+ if SETTINGS.currency == 'RUB' and price < settings_module.MIN_INVOICE_RUB:
+ await callback.answer(
+ f'Итоговая сумма {price:.2f} ₽ меньше минимальной для Telegram '
+ f'({settings_module.MIN_INVOICE_RUB:g} ₽).',
+ show_alert=True,
+ )
+ return
+
+ await callback.answer()
+ try:
+ await bot.send_invoice(
+ chat_id_of(callback),
+ **payments.build_invoice(SETTINGS, period, discount, promocode),
+ )
+ except TelegramBadRequest as e:
+ logger.error(f"Не удалось выставить счёт: {e}")
+ await bot.send_message(
+ chat_id_of(callback),
+ 'Не удалось выставить счёт. Проверьте токен платёжного провайдера в настройках бота.',
+ )
+
+
+@payment_router.pre_checkout_query()
+async def process_pre_checkout(query: PreCheckoutQuery):
+ """Telegram ждёт ответ в течение 10 секунд, иначе оплата отменяется."""
+ period, _ = payments.parse_payload(query.invoice_payload)
+ if not SETTINGS.payments_enabled or not period:
+ await query.answer(ok=False, error_message='Счёт устарел, оформите заказ заново.')
+ return
+ await query.answer(ok=True)
+
+
+@payment_router.message(F.successful_payment)
+async def process_successful_payment(message: Message, state: FSMContext):
+ payment = message.successful_payment
+ period, promocode = payments.parse_payload(payment.invoice_payload)
+ charge_id = payment.provider_payment_charge_id or payment.telegram_payment_charge_id
+
+ if not period:
+ logger.error(f"Оплата {charge_id} с нераспознанным payload: {payment.invoice_payload!r}")
+ await message.answer('Оплата получена, но заказ не распознан. Обратитесь к администратору.')
+ await notify_admins(f'❗ Оплата {charge_id} с некорректным payload: {payment.invoice_payload!r}')
+ return
+
+ await state.clear()
+ await grant_paid_subscription(
+ message.from_user.id, message.chat.id, period, promocode,
+ amount=payment.total_amount / 100, currency=payment.currency, charge_id=charge_id,
+ )
+
+
+async def grant_paid_subscription(user_id, chat_id, period, promocode, amount, currency, charge_id):
+ """Выдаёт ключ после оплаты. Повторная доставка платежа ключ не дублирует."""
+ if db.is_payment_recorded(charge_id):
+ logger.info(f"Платёж {charge_id} уже обработан — повторная выдача пропущена.")
+ return
+
+ status = await bot.send_message(chat_id, 'Оплата получена. Создаю ключ...')
+ username = await vpn.issue_subscription(user_id, period, SETTINGS)
+ await status.delete()
+
+ if not username:
+ # Деньги списаны, а ключ не создан — это должен увидеть администратор.
+ logger.error(f"Не удалось создать клиента после оплаты {charge_id} (пользователь {user_id}).")
+ await bot.send_message(
+ chat_id,
+ 'Оплата прошла, но выдать ключ автоматически не получилось. '
+ 'Администратор уже уведомлён и свяжется с вами.',
+ )
+ await notify_admins(
+ f'❗ Оплата {charge_id} на {amount} {currency} от пользователя {user_id} '
+ f'прошла, но клиент не создан. Требуется ручная выдача ключа.'
+ )
+ return
+
+ db.record_payment(charge_id, user_id, username, period, amount, currency, promocode)
+ if promocode:
+ db.consume_promocode(promocode)
+
+ await send_client_config(
+ chat_id, username, header=f'Ваш VPN-ключ на {esc(config.period_label(period))}',
+ )
+ await bot.send_message(
+ chat_id,
+ f'Подписка активна до {esc(format_expiration(db.get_user_expiration(username)))}.',
+ reply_markup=kb.main_menu(user_id, SETTINGS),
+ )
+
+
+# --- Ключи пользователя -----------------------------------------------------
+
+@router.callback_query(F.data == 'my_keys')
+async def my_keys(callback: CallbackQuery, state: FSMContext):
+ await state.clear()
+ usernames = db.get_usernames_by_telegram_id(callback.from_user.id)
+ usernames = [name for name in usernames if vpn.client_conf_path(name).exists()]
+ if not usernames:
+ await show(callback, 'У вас пока нет ключей.', kb.main_menu(callback.from_user.id, SETTINGS))
+ await callback.answer()
+ return
+
+ lines = []
+ for name in usernames:
+ lines.append(f'• {esc(name)} — до {esc(format_expiration(db.get_user_expiration(name)))}')
+ await show(callback, 'Ваши ключи:\n' + '\n'.join(lines), kb.main_menu(callback.from_user.id, SETTINGS))
+ await callback.answer()
+ for name in usernames:
+ await send_client_config(chat_id_of(callback), name)
+
+
+# --- Резервное копирование --------------------------------------------------
+
+@router.callback_query(F.data == 'create_backup')
+async def create_backup(callback: CallbackQuery):
+ if not SETTINGS.is_admin(callback.from_user.id):
+ await deny(callback)
+ return
+ await callback.answer('Готовлю бэкап...')
+
+ backup_path = config.BASE_DIR / f"backup_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.zip"
+ try:
+ await asyncio.to_thread(_write_backup, backup_path)
+ await bot.send_document(
+ chat_id_of(callback),
+ FSInputFile(backup_path, filename=backup_path.name),
+ caption='Резервная копия конфигураций и данных бота.',
+ )
+ except Exception as e:
+ logger.error(f"Ошибка создания бэкапа: {e}")
+ await bot.send_message(chat_id_of(callback), f'Не удалось создать бэкап: {esc(e)}')
+ finally:
+ backup_path.unlink(missing_ok=True)
+
+
+def _write_backup(backup_path):
+ """Складывает в архив скрипты, files/ и users/."""
+ with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as archive:
+ for name in ('awg_decode.py', 'newclient.sh', 'removeclient.sh'):
+ script = config.BASE_DIR / name
+ if script.exists():
+ archive.write(script, name)
+ for directory in (config.FILES_DIR, config.USERS_DIR):
+ if not directory.exists():
+ continue
+ for path in directory.rglob('*'):
+ if path.is_file():
+ archive.write(path, path.relative_to(config.BASE_DIR))
+
+
+# --- Истечение подписок -----------------------------------------------------
+
+async def enforce_expirations():
+ """Удаляет клиентов с истёкшей подпиской и сообщает об этом владельцам."""
+ now = datetime.now(pytz.utc)
+ for username, expiration in db.get_all_expirations().items():
+ if not expiration or expiration > now:
+ continue
+ if not vpn.client_conf_path(username).exists():
+ db.remove_user_expiration(username)
+ continue
+
+ owner = db.get_user_telegram_id(username)
+ logger.info(f"Подписка {username} истекла {expiration} — удаляю клиента.")
+ if not await vpn.delete_client(username, SETTINGS):
+ logger.error(f"Не удалось удалить клиента {username} с истёкшей подпиской.")
+ continue
+ if owner:
+ try:
+ await bot.send_message(
+ owner,
+ f'Срок действия ключа {esc(username)} истёк, доступ отключён.',
+ reply_markup=kb.main_menu(owner, SETTINGS),
+ )
+ except Exception as e:
+ logger.warning(f"Не удалось уведомить {owner} об истечении подписки: {e}")
+
+
+# --- Фолбэк для сообщений вне диалогов --------------------------------------
+
+@router.message(F.text)
+async def fallback(message: Message, state: FSMContext):
+ """Любое сообщение вне диалога возвращает пользователя в главное меню."""
+ if await state.get_state() is None:
+ await show_main_menu(message, state)
+
+
+# --- Запуск -----------------------------------------------------------------
+
+async def main():
+ scheduler.add_job(enforce_expirations, 'interval', hours=1, next_run_time=datetime.now(pytz.utc))
+ scheduler.start()
+ logger.info(
+ f"Бот запущен. Каталог: {config.BASE_DIR}. "
+ f"Оплата: {'включена' if SETTINGS.payments_enabled else 'отключена'}."
+ )
+ await bot.delete_webhook(drop_pending_updates=True)
+ try:
+ await dp.start_polling(bot)
+ finally:
+ scheduler.shutdown(wait=False)
+ await bot.session.close()
+
if __name__ == '__main__':
- executor.start_polling(dp, skip_updates=True)
+ try:
+ asyncio.run(main())
+ except (KeyboardInterrupt, SystemExit):
+ logger.info('Бот остановлен.')
diff --git a/awg/config.py b/awg/config.py
new file mode 100644
index 0000000..39985c8
--- /dev/null
+++ b/awg/config.py
@@ -0,0 +1,65 @@
+"""Пути и настройки бота.
+
+Все пути вычисляются относительно расположения этого файла, поэтому бот
+не привязан к конкретному каталогу установки и не зависит от текущего cwd.
+Каталог можно переопределить переменной окружения AWG_BOT_HOME.
+"""
+import os
+import re
+import sys
+from pathlib import Path
+
+BASE_DIR = Path(os.environ.get('AWG_BOT_HOME') or Path(__file__).resolve().parent)
+
+FILES_DIR = BASE_DIR / 'files'
+USERS_DIR = BASE_DIR / 'users'
+
+CONFIG_FILE = FILES_DIR / 'config.json'
+USER_EXPIRATION_FILE = FILES_DIR / 'user_expiration.json'
+USER_TELEGRAM_FILE = FILES_DIR / 'user_telegram.json'
+PROMOCODES_FILE = FILES_DIR / 'promocodes.json'
+PAYMENTS_FILE = FILES_DIR / 'payments.json'
+SERVER_CONF_FILE = FILES_DIR / 'server.conf'
+CLIENTS_TABLE_FILE = FILES_DIR / 'clientsTable'
+
+NEWCLIENT_SCRIPT = BASE_DIR / 'newclient.sh'
+REMOVECLIENT_SCRIPT = BASE_DIR / 'removeclient.sh'
+
+# Интерпретатор, которым запущен бот, — не захардкоженная версия Python.
+PYTHON_EXECUTABLE = sys.executable or 'python3'
+
+# Имя клиента должно быть безопасным для файловой системы и для newclient.sh.
+USERNAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,32}$')
+
+DEFAULT_PRICING = {
+ '1_month': 1000.0,
+ '3_months': 2500.0,
+ '6_months': 4500.0,
+ '12_months': 8000.0,
+}
+
+# Периоды подписки и их длительность в месяцах.
+PERIODS = {
+ '1_month': 1,
+ '3_months': 3,
+ '6_months': 6,
+ '12_months': 12,
+}
+
+PERIOD_LABELS = {
+ '1_month': '1 месяц',
+ '3_months': '3 месяца',
+ '6_months': '6 месяцев',
+ '12_months': '12 месяцев',
+}
+
+
+def period_label(period):
+ """Человекочитаемое название периода подписки."""
+ return PERIOD_LABELS.get(period, str(period).replace('_', ' '))
+
+
+def ensure_dirs():
+ """Создаёт рабочие каталоги, если их ещё нет."""
+ FILES_DIR.mkdir(parents=True, exist_ok=True)
+ USERS_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/awg/db.py b/awg/db.py
index cb78d1b..ce26fd1 100644
--- a/awg/db.py
+++ b/awg/db.py
@@ -1,165 +1,273 @@
+"""Хранилище данных бота (JSON-файлы) и операции с клиентами WireGuard."""
import json
-import os
-import subprocess
import logging
+import re
+import subprocess
from datetime import datetime
-import pytz
-import shutil
-logging.basicConfig(level=logging.INFO)
+import pytz
+
+import config
+
logger = logging.getLogger(__name__)
-CONFIG_FILE = 'files/config.json'
-USER_EXPIRATION_FILE = 'files/user_expiration.json'
-USER_TELEGRAM_FILE = 'files/user_telegram.json'
-PROMOCODES_FILE = 'files/promocodes.json'
def load_json(file_path, default=None):
"""Загружает JSON-файл, возвращает default при ошибке или отсутствии файла."""
try:
- if os.path.exists(file_path):
- with open(file_path, 'r') as f:
+ if file_path.exists():
+ with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
- logger.error(f"Ошибка загрузки {file_path}: {str(e)}")
+ logger.error(f"Ошибка загрузки {file_path}: {e}")
return default if default is not None else {}
+
def save_json(file_path, data):
- """Сохраняет данные в JSON-файл."""
+ """Сохраняет данные в JSON-файл (атомарно, через временный файл)."""
try:
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
- with open(file_path, 'w') as f:
- json.dump(data, f, indent=4, default=str)
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ tmp_path = file_path.with_suffix(file_path.suffix + '.tmp')
+ with open(tmp_path, 'w', encoding='utf-8') as f:
+ json.dump(data, f, indent=4, ensure_ascii=False, default=str)
+ tmp_path.replace(file_path)
return True
except Exception as e:
- logger.error(f"Ошибка сохранения {file_path}: {str(e)}")
+ logger.error(f"Ошибка сохранения {file_path}: {e}")
return False
+
+# --- Конфигурация -----------------------------------------------------------
+
def get_config():
"""Возвращает конфигурацию из config.json."""
- return load_json(CONFIG_FILE, {})
+ return load_json(config.CONFIG_FILE, {})
+
+
+def save_config(cfg):
+ """Сохраняет конфигурацию в config.json."""
+ return save_json(config.CONFIG_FILE, cfg)
+
def add_admin(admin_id):
"""Добавляет ID администратора в конфигурацию."""
- config = get_config()
- admin_ids = config.get('admin_ids', [])
+ cfg = get_config()
+ admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
if str(admin_id) not in admin_ids:
admin_ids.append(str(admin_id))
- config['admin_ids'] = admin_ids
- save_json(CONFIG_FILE, config)
+ cfg['admin_ids'] = admin_ids
+ save_config(cfg)
+
def remove_admin(admin_id):
"""Удаляет ID администратора из конфигурации."""
- config = get_config()
- admin_ids = config.get('admin_ids', [])
- admin_id_str = str(admin_id)
- if admin_id_str in admin_ids:
- admin_ids.remove(admin_id_str)
- config['admin_ids'] = admin_ids
- save_json(CONFIG_FILE, config)
+ cfg = get_config()
+ admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
+ if str(admin_id) in admin_ids:
+ admin_ids.remove(str(admin_id))
+ cfg['admin_ids'] = admin_ids
+ save_config(cfg)
+
def set_pricing(period, price):
"""Устанавливает цену для указанного периода подписки."""
- config = get_config()
- config['pricing'] = config.get('pricing', {})
- config['pricing'][period] = price
- save_json(CONFIG_FILE, config)
+ cfg = get_config()
+ pricing = cfg.get('pricing', dict(config.DEFAULT_PRICING))
+ pricing[period] = price
+ cfg['pricing'] = pricing
+ save_config(cfg)
-def root_add(name, ipv6=False):
- """Добавляет нового пользователя через newclient.sh."""
+
+# --- Клиенты WireGuard ------------------------------------------------------
+
+def _client_conf_path(name):
+ return config.USERS_DIR / name / f'{name}.conf'
+
+
+def get_client_public_key(name, docker_container):
+ """Возвращает публичный ключ клиента.
+
+ Ключ выводится из приватного ключа в конфиге клиента; если конфига нет,
+ используется clientsTable, который ведёт newclient.sh.
+ """
+ conf_path = _client_conf_path(name)
+ if conf_path.exists():
+ try:
+ conf = conf_path.read_text(encoding='utf-8')
+ match = re.search(r'^PrivateKey\s*=\s*(\S+)', conf, re.MULTILINE)
+ if match:
+ result = subprocess.run(
+ ['docker', 'exec', '-i', docker_container, 'wg', 'pubkey'],
+ input=match.group(1), capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip()
+ logger.error(f"wg pubkey для {name} завершился с ошибкой: {result.stderr.strip()}")
+ except Exception as e:
+ logger.error(f"Не удалось вычислить публичный ключ для {name}: {e}")
+
+ for entry in load_json(config.CLIENTS_TABLE_FILE, []):
+ if entry.get('userData', {}).get('clientName') == name:
+ return entry.get('clientId')
+ return None
+
+
+def root_add(name, endpoint, wg_config_file, docker_container):
+ """Создаёт нового клиента через newclient.sh."""
try:
- cmd = ['./newclient.sh', name]
- if not ipv6:
- cmd.append('--no-ipv6')
- process = subprocess.run(cmd, capture_output=True, text=True)
+ process = subprocess.run(
+ [str(config.NEWCLIENT_SCRIPT), name, endpoint, wg_config_file, docker_container],
+ capture_output=True, text=True, cwd=str(config.BASE_DIR), timeout=120,
+ )
if process.returncode == 0:
return True
- logger.error(f"Ошибка добавления пользователя {name}: {process.stderr}")
+ logger.error(f"Ошибка добавления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False
except Exception as e:
- logger.error(f"Исключение при добавлении пользователя {name}: {str(e)}")
+ logger.error(f"Исключение при добавлении клиента {name}: {e}")
return False
-def deactive_user_db(name):
- """Деактивирует пользователя через removeclient.sh."""
+
+def deactive_user_db(name, wg_config_file, docker_container):
+ """Удаляет клиента через removeclient.sh."""
+ public_key = get_client_public_key(name, docker_container)
+ if not public_key:
+ logger.error(f"Не найден публичный ключ клиента {name} — удаление невозможно.")
+ return False
try:
- process = subprocess.run(['./removeclient.sh', name], capture_output=True, text=True)
+ process = subprocess.run(
+ [str(config.REMOVECLIENT_SCRIPT), name, public_key, wg_config_file, docker_container],
+ capture_output=True, text=True, cwd=str(config.BASE_DIR), timeout=120,
+ )
if process.returncode == 0:
return True
- logger.error(f"Ошибка удаления пользователя {name}: {process.stderr}")
+ logger.error(f"Ошибка удаления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False
except Exception as e:
- logger.error(f"Исключение при удалении пользователя {name}: {str(e)}")
+ logger.error(f"Исключение при удалении клиента {name}: {e}")
return False
+
def get_client_list():
- """Возвращает список клиентов (имя и конфигурация)."""
+ """Возвращает список клиентов в виде пар (имя, конфигурация)."""
clients = []
- users_dir = 'users'
- if os.path.exists(users_dir):
- for user_dir in os.listdir(users_dir):
- user_path = os.path.join(users_dir, user_dir)
- if os.path.isdir(user_path):
- conf_file = os.path.join(user_path, f"{user_dir}.conf")
- if os.path.exists(conf_file):
- with open(conf_file, 'r') as f:
- config = f.read()
- clients.append((user_dir, config))
+ if not config.USERS_DIR.exists():
+ return clients
+ for user_dir in sorted(config.USERS_DIR.iterdir()):
+ if not user_dir.is_dir():
+ continue
+ conf_file = user_dir / f'{user_dir.name}.conf'
+ if conf_file.exists():
+ clients.append((user_dir.name, conf_file.read_text(encoding='utf-8')))
return clients
-def get_active_list():
- """Возвращает список активных клиентов с последним handshake."""
- active = []
- users_dir = 'users'
- if os.path.exists(users_dir):
- for user_dir in os.listdir(users_dir):
- user_path = os.path.join(users_dir, user_dir)
- if os.path.isdir(user_path):
- status_file = os.path.join(user_path, 'status.json')
- if os.path.exists(status_file):
- with open(status_file, 'r') as f:
- status = json.load(f)
- last_handshake = status.get('last_handshake', 'never')
- active.append((user_dir, last_handshake))
+
+def get_active_list(wg_config_file, docker_container):
+ """Возвращает {имя клиента: время последнего handshake (UTC) или None}.
+
+ Данные берутся из `wg show latest-handshakes` внутри контейнера Amnezia.
+ """
+ interface = wg_config_file.rsplit('/', 1)[-1].rsplit('.', 1)[0]
+ handshakes = {}
+ try:
+ result = subprocess.run(
+ ['docker', 'exec', '-i', docker_container, 'wg', 'show', interface, 'latest-handshakes'],
+ capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode != 0:
+ logger.error(f"wg show завершился с ошибкой: {result.stderr.strip()}")
+ return {}
+ for line in result.stdout.splitlines():
+ parts = line.split()
+ if len(parts) != 2:
+ continue
+ timestamp = int(parts[1])
+ handshakes[parts[0]] = (
+ datetime.fromtimestamp(timestamp, pytz.utc) if timestamp > 0 else None
+ )
+ except Exception as e:
+ logger.error(f"Не удалось получить статусы клиентов: {e}")
+ return {}
+
+ active = {}
+ for entry in load_json(config.CLIENTS_TABLE_FILE, []):
+ name = entry.get('userData', {}).get('clientName')
+ if name and entry.get('clientId') in handshakes:
+ active[name] = handshakes[entry['clientId']]
return active
-def set_user_expiration(username, expiration, transfer_limit):
- """Устанавливает срок действия и лимит трафика для пользователя."""
- data = load_json(USER_EXPIRATION_FILE, {})
+
+# --- Подписки ---------------------------------------------------------------
+
+def set_user_expiration(username, expiration, transfer_limit='Неограниченно'):
+ """Устанавливает срок действия подписки пользователя."""
+ data = load_json(config.USER_EXPIRATION_FILE, {})
data[username] = {
'expiration': expiration.isoformat() if expiration else None,
- 'transfer_limit': transfer_limit
+ 'transfer_limit': transfer_limit,
}
- save_json(USER_EXPIRATION_FILE, data)
+ save_json(config.USER_EXPIRATION_FILE, data)
+
def get_user_expiration(username):
- """Получает срок действия подписки пользователя."""
- data = load_json(USER_EXPIRATION_FILE, {})
- user_data = data.get(username, {})
- expiration = user_data.get('expiration')
- return datetime.fromisoformat(expiration) if expiration else None
+ """Возвращает срок действия подписки пользователя (aware datetime в UTC)."""
+ data = load_json(config.USER_EXPIRATION_FILE, {})
+ expiration = data.get(username, {}).get('expiration')
+ if not expiration:
+ return None
+ try:
+ parsed = datetime.fromisoformat(expiration)
+ except ValueError:
+ logger.error(f"Некорректная дата окончания подписки у {username}: {expiration}")
+ return None
+ return parsed if parsed.tzinfo else pytz.utc.localize(parsed)
+
+
+def get_all_expirations():
+ """Возвращает {имя клиента: дата окончания подписки} для всех клиентов."""
+ data = load_json(config.USER_EXPIRATION_FILE, {})
+ return {username: get_user_expiration(username) for username in data}
+
def remove_user_expiration(username):
"""Удаляет информацию о сроке действия подписки пользователя."""
- data = load_json(USER_EXPIRATION_FILE, {})
+ data = load_json(config.USER_EXPIRATION_FILE, {})
if username in data:
del data[username]
- save_json(USER_EXPIRATION_FILE, data)
+ save_json(config.USER_EXPIRATION_FILE, data)
+
def set_user_telegram_id(username, telegram_id):
- """Связывает имя пользователя с Telegram ID."""
- data = load_json(USER_TELEGRAM_FILE, {})
+ """Связывает имя клиента с Telegram ID."""
+ data = load_json(config.USER_TELEGRAM_FILE, {})
data[username] = telegram_id
- save_json(USER_TELEGRAM_FILE, data)
+ save_json(config.USER_TELEGRAM_FILE, data)
+
def get_user_telegram_id(username):
- """Получает Telegram ID пользователя по имени."""
- data = load_json(USER_TELEGRAM_FILE, {})
- return data.get(username)
+ """Возвращает Telegram ID владельца клиента."""
+ return load_json(config.USER_TELEGRAM_FILE, {}).get(username)
+
+
+def remove_user_telegram_id(username):
+ """Удаляет связь клиента с Telegram ID."""
+ data = load_json(config.USER_TELEGRAM_FILE, {})
+ if username in data:
+ del data[username]
+ save_json(config.USER_TELEGRAM_FILE, data)
+
+
+def get_usernames_by_telegram_id(telegram_id):
+ """Возвращает имена клиентов, принадлежащих пользователю Telegram."""
+ data = load_json(config.USER_TELEGRAM_FILE, {})
+ return [name for name, owner in data.items() if owner == telegram_id]
+
+
+# --- Промокоды --------------------------------------------------------------
def add_promocode(code, discount, expires_at, max_uses, subscription_period):
"""Добавляет новый промокод."""
- promocodes = load_json(PROMOCODES_FILE, {})
+ promocodes = load_json(config.PROMOCODES_FILE, {})
if code in promocodes:
return False
promocodes[code] = {
@@ -167,48 +275,95 @@ def add_promocode(code, discount, expires_at, max_uses, subscription_period):
'expires_at': expires_at.isoformat() if expires_at else None,
'max_uses': max_uses,
'uses': 0,
- 'subscription_period': subscription_period
+ 'subscription_period': subscription_period,
}
- save_json(PROMOCODES_FILE, promocodes)
+ save_json(config.PROMOCODES_FILE, promocodes)
return True
-def apply_promocode(code):
- """Применяет промокод, увеличивает счетчик использований."""
- promocodes = load_json(PROMOCODES_FILE, {})
- now = datetime.now(pytz.utc)
- promo = promocodes.get(code)
+
+def validate_promocode(code):
+ """Проверяет промокод, НЕ расходуя использование.
+
+ Возвращает {'discount': ..., 'subscription_period': ...} или None.
+ Счётчик использований увеличивает только consume_promocode().
+ """
+ promo = load_json(config.PROMOCODES_FILE, {}).get(code)
if not promo:
return None
- if promo['expires_at'] and datetime.fromisoformat(promo['expires_at']) < now:
+ if promo['expires_at'] and datetime.fromisoformat(promo['expires_at']) < datetime.now(pytz.utc):
return None
if promo['max_uses'] is not None and promo['uses'] >= promo['max_uses']:
return None
- promo['uses'] += 1
- save_json(PROMOCODES_FILE, promocodes)
return {
'discount': promo['discount'],
- 'subscription_period': promo['subscription_period']
+ 'subscription_period': promo['subscription_period'],
}
+
+def consume_promocode(code):
+ """Расходует одно использование промокода после успешной выдачи ключа."""
+ promocodes = load_json(config.PROMOCODES_FILE, {})
+ promo = promocodes.get(code)
+ if not promo:
+ return False
+ promo['uses'] += 1
+ save_json(config.PROMOCODES_FILE, promocodes)
+ return True
+
+
def get_promocodes():
- """Возвращает список всех промокодов."""
- promocodes = load_json(PROMOCODES_FILE, {})
+ """Возвращает все промокоды с разобранными датами."""
result = {}
- for code, info in promocodes.items():
+ for code, info in load_json(config.PROMOCODES_FILE, {}).items():
result[code] = {
'discount': info['discount'],
'expires_at': datetime.fromisoformat(info['expires_at']) if info['expires_at'] else None,
'max_uses': info['max_uses'],
'uses': info['uses'],
- 'subscription_period': info['subscription_period']
+ 'subscription_period': info['subscription_period'],
}
return result
+
def remove_promocode(code):
"""Удаляет промокод."""
- promocodes = load_json(PROMOCODES_FILE, {})
+ promocodes = load_json(config.PROMOCODES_FILE, {})
if code in promocodes:
del promocodes[code]
- save_json(PROMOCODES_FILE, promocodes)
+ save_json(config.PROMOCODES_FILE, promocodes)
return True
return False
+
+
+# --- Платежи ----------------------------------------------------------------
+
+def record_payment(charge_id, telegram_id, username, period, amount, currency, promocode=None):
+ """Сохраняет успешный платёж. Возвращает False, если платёж уже записан.
+
+ Telegram может доставить successful_payment повторно, поэтому запись
+ идемпотентна по идентификатору платежа провайдера.
+ """
+ payments = load_json(config.PAYMENTS_FILE, {})
+ if charge_id in payments:
+ return False
+ payments[charge_id] = {
+ 'telegram_id': telegram_id,
+ 'username': username,
+ 'period': period,
+ 'amount': amount,
+ 'currency': currency,
+ 'promocode': promocode,
+ 'paid_at': datetime.now(pytz.utc).isoformat(),
+ }
+ save_json(config.PAYMENTS_FILE, payments)
+ return True
+
+
+def is_payment_recorded(charge_id):
+ """Проверяет, обработан ли уже платёж с таким идентификатором."""
+ return charge_id in load_json(config.PAYMENTS_FILE, {})
+
+
+def get_payments():
+ """Возвращает все записанные платежи."""
+ return load_json(config.PAYMENTS_FILE, {})
diff --git a/awg/files/config.json.example b/awg/files/config.json.example
new file mode 100644
index 0000000..92d4c6f
--- /dev/null
+++ b/awg/files/config.json.example
@@ -0,0 +1,26 @@
+{
+ "bot_token": "123456789:AA...ваш токен от @BotFather",
+
+ "admin_ids": ["123456789"],
+ "moderator_ids": [],
+
+ "wg_config_file": "/opt/amnezia/awg/wg0.conf",
+ "docker_container": "amnezia-awg",
+ "endpoint": "203.0.113.10",
+
+ "payment_provider_token": "381764678:TEST:0000000000",
+ "payment_currency": "RUB",
+
+ "pricing": {
+ "1_month": 1000.0,
+ "3_months": 2500.0,
+ "6_months": 4500.0,
+ "12_months": 8000.0
+ },
+
+ "payment_receipt": {
+ "enabled": false,
+ "vat_code": 1,
+ "tax_system_code": 1
+ }
+}
diff --git a/awg/keyboards.py b/awg/keyboards.py
new file mode 100644
index 0000000..477a6b0
--- /dev/null
+++ b/awg/keyboards.py
@@ -0,0 +1,177 @@
+"""Инлайн-клавиатуры бота.
+
+В callback_data используется двоеточие как разделитель: имена клиентов
+состоят из [a-zA-Z0-9_-], поэтому двоеточие гарантированно не встречается
+внутри значения и разбор не ломается на именах с подчёркиваниями.
+"""
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+import config
+
+HOME = InlineKeyboardButton(text='🏠 Домой', callback_data='home')
+
+
+def _finish(builder, *rows):
+ for row in rows:
+ builder.row(*row)
+ return builder.as_markup()
+
+
+def main_menu(user_id, settings):
+ """Главное меню, состав зависит от роли пользователя."""
+ builder = InlineKeyboardBuilder()
+ if settings.is_admin(user_id):
+ builder.row(
+ InlineKeyboardButton(text='➕ Добавить пользователя', callback_data='add_user'),
+ InlineKeyboardButton(text='📋 Список клиентов', callback_data='list_users'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='🔑 Получить конфиг', callback_data='get_config'),
+ InlineKeyboardButton(text='🎟️ Управление промокодами', callback_data='manage_promocodes'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='⚙️ Настройки', callback_data='settings'),
+ HOME,
+ )
+ elif settings.is_staff(user_id):
+ builder.row(
+ InlineKeyboardButton(text='➕ Добавить пользователя', callback_data='add_user'),
+ InlineKeyboardButton(text='📋 Список клиентов', callback_data='list_users'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='🔑 Получить конфиг', callback_data='get_config'),
+ HOME,
+ )
+ else:
+ if settings.payments_enabled:
+ builder.row(InlineKeyboardButton(text='💳 Купить ключ', callback_data='buy_key'))
+ builder.row(InlineKeyboardButton(text='🎟️ Получить ключ по промокоду', callback_data='use_promocode'))
+ builder.row(InlineKeyboardButton(text='🔑 Мои ключи', callback_data='my_keys'))
+ return builder.as_markup()
+
+
+def buy_menu(settings, discount=0.0):
+ """Выбор периода подписки с ценами (при наличии — со скидкой)."""
+ builder = InlineKeyboardBuilder()
+ for period in config.PERIODS:
+ price = settings.price(period) * (1 - discount / 100)
+ builder.row(InlineKeyboardButton(
+ text=f'{config.period_label(period)} — {price:,.0f} ₽'.replace(',', ' '),
+ callback_data=f'buy:{period}',
+ ))
+ builder.row(InlineKeyboardButton(text='🎟️ Ввести промокод', callback_data='use_promocode'))
+ builder.row(HOME)
+ return builder.as_markup()
+
+
+def settings_menu():
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(text='💾 Создать бэкап', callback_data='create_backup'),
+ InlineKeyboardButton(text='👥 Список админов', callback_data='list_admins'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='👤 Добавить админа', callback_data='add_admin'),
+ InlineKeyboardButton(text='💰 Настройки цен', callback_data='pricing_settings'),
+ )
+ builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='home'))
+ return builder.as_markup()
+
+
+def pricing_settings_menu(settings):
+ builder = InlineKeyboardBuilder()
+ for period in config.PERIODS:
+ builder.row(InlineKeyboardButton(
+ text=f'{config.period_label(period)}: {settings.price(period):,.0f} ₽'.replace(',', ' '),
+ callback_data=f'setprice:{period}',
+ ))
+ builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='settings'))
+ return builder.as_markup()
+
+
+def renewal_periods(username):
+ builder = InlineKeyboardBuilder()
+ for period in config.PERIODS:
+ builder.add(InlineKeyboardButton(
+ text=config.period_label(period),
+ callback_data=f'renewp:{username}:{period}',
+ ))
+ builder.adjust(2)
+ builder.row(InlineKeyboardButton(text='📅 Указать дату', callback_data=f'renewp:{username}:custom'))
+ builder.row(
+ InlineKeyboardButton(text='⬅️ Назад', callback_data=f'client:{username}'),
+ HOME,
+ )
+ return builder.as_markup()
+
+
+def client_actions(username):
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'delete:{username}'),
+ InlineKeyboardButton(text='🔄 Продлить', callback_data=f'renew:{username}'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='🔑 Конфиг', callback_data=f'sendconf:{username}'),
+ )
+ builder.row(
+ InlineKeyboardButton(text='⬅️ Назад', callback_data='list_users'),
+ HOME,
+ )
+ return builder.as_markup()
+
+
+def client_list(clients, callback_prefix, back_callback='home'):
+ """Список клиентов; clients — пары (имя, префикс статуса)."""
+ builder = InlineKeyboardBuilder()
+ for username, status in clients:
+ builder.add(InlineKeyboardButton(
+ text=f'{status} {username}'.strip(),
+ callback_data=f'{callback_prefix}:{username}',
+ ))
+ builder.adjust(2)
+ builder.row(InlineKeyboardButton(text='🏠 Домой', callback_data=back_callback))
+ return builder.as_markup()
+
+
+def admin_list(admin_ids):
+ builder = InlineKeyboardBuilder()
+ for admin_id in sorted(admin_ids):
+ builder.add(InlineKeyboardButton(
+ text=f'🗑️ Удалить {admin_id}',
+ callback_data=f'rmadmin:{admin_id}',
+ ))
+ builder.adjust(2)
+ builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='settings'))
+ return builder.as_markup()
+
+
+def promocodes_menu():
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(text='➕ Добавить промокод', callback_data='add_promocode'),
+ InlineKeyboardButton(text='🗑️ Удалить промокод', callback_data='delete_promocode'),
+ )
+ builder.row(HOME)
+ return builder.as_markup()
+
+
+def promocode_list(codes):
+ builder = InlineKeyboardBuilder()
+ for code in codes:
+ builder.add(InlineKeyboardButton(text=f'🗑️ {code}', callback_data=f'rmpromo:{code}'))
+ builder.adjust(2)
+ builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='manage_promocodes'))
+ return builder.as_markup()
+
+
+def single(text, callback_data):
+ """Клавиатура из одной кнопки."""
+ return InlineKeyboardMarkup(inline_keyboard=[[
+ InlineKeyboardButton(text=text, callback_data=callback_data),
+ ]])
+
+
+def home_only():
+ return InlineKeyboardMarkup(inline_keyboard=[[HOME]])
diff --git a/awg/newclient.sh b/awg/newclient.sh
old mode 100644
new mode 100755
index 2f7381f..50eda07
--- a/awg/newclient.sh
+++ b/awg/newclient.sh
@@ -32,7 +32,8 @@ if [[ ! "$CLIENT_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
exit 1
fi
-pwd=$(pwd)
+# Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
+pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/users/$CLIENT_NAME"
mkdir -p "$pwd/files"
diff --git a/awg/payments.py b/awg/payments.py
new file mode 100644
index 0000000..df630ba
--- /dev/null
+++ b/awg/payments.py
@@ -0,0 +1,94 @@
+"""Приём оплаты через встроенные платежи Telegram (провайдер — ЮKassa).
+
+Токен провайдера выдаёт @BotFather (Payments -> ЮKassa) и кладётся в
+files/config.json как payment_provider_token либо в переменную окружения
+AWG_PAYMENT_PROVIDER_TOKEN. Никакой публичный webhook при этом не нужен:
+Telegram сам подтверждает оплату апдейтами pre_checkout_query и
+successful_payment.
+"""
+import json
+import logging
+
+from aiogram.types import LabeledPrice
+
+import config
+
+logger = logging.getLogger(__name__)
+
+PAYLOAD_PREFIX = 'sub'
+NO_PROMOCODE = '-'
+
+
+def to_minor_units(amount):
+ """Переводит рубли в копейки — Telegram принимает только целые единицы."""
+ return int(round(float(amount) * 100))
+
+
+def apply_discount(price, discount):
+ """Применяет скидку в процентах, не опускаясь ниже нуля."""
+ discounted = float(price) * (1 - float(discount or 0) / 100)
+ return max(discounted, 0.0)
+
+
+def final_price(settings, period, discount=0.0):
+ """Итоговая цена периода подписки с учётом скидки."""
+ return apply_discount(settings.price(period), discount)
+
+
+def build_payload(period, promocode=None):
+ """Собирает payload счёта (Telegram ограничивает его 128 байтами)."""
+ return f'{PAYLOAD_PREFIX}:{period}:{promocode or NO_PROMOCODE}'
+
+
+def parse_payload(payload):
+ """Разбирает payload счёта. Возвращает (период, промокод) или (None, None)."""
+ parts = (payload or '').split(':')
+ if len(parts) != 3 or parts[0] != PAYLOAD_PREFIX:
+ return None, None
+ period = parts[1] if parts[1] in config.PERIODS else None
+ promocode = None if parts[2] == NO_PROMOCODE else parts[2]
+ return period, promocode
+
+
+def _receipt_provider_data(settings, price, description):
+ """Чек для 54-ФЗ, если он включён в настройках."""
+ receipt = getattr(settings, 'receipt', None)
+ if not receipt or not receipt.get('enabled'):
+ return None
+ return {
+ 'receipt': {
+ 'items': [{
+ 'description': description[:128],
+ 'quantity': '1.00',
+ 'amount': {'value': f'{price:.2f}', 'currency': settings.currency},
+ 'vat_code': receipt.get('vat_code', 1),
+ }],
+ 'tax_system_code': receipt.get('tax_system_code', 1),
+ }
+ }
+
+
+def build_invoice(settings, period, discount=0.0, promocode=None):
+ """Готовит именованные аргументы для bot.send_invoice."""
+ price = final_price(settings, period, discount)
+ label = config.period_label(period)
+ title = f'VPN-ключ на {label}'
+ description = f'Доступ к AmneziaVPN на {label}.'
+ if discount:
+ description += f' Промокод {promocode}: скидка {discount:g}%.'
+
+ invoice = {
+ 'title': title,
+ 'description': description,
+ 'payload': build_payload(period, promocode),
+ 'provider_token': settings.provider_token,
+ 'currency': settings.currency,
+ 'prices': [LabeledPrice(label=label, amount=to_minor_units(price))],
+ }
+
+ provider_data = _receipt_provider_data(settings, price, title)
+ if provider_data:
+ invoice['provider_data'] = json.dumps(provider_data, ensure_ascii=False)
+ invoice['need_email'] = True
+ invoice['send_email_to_provider'] = True
+ return invoice
diff --git a/awg/removeclient.sh b/awg/removeclient.sh
old mode 100644
new mode 100755
index db710fa..6337cfe
--- a/awg/removeclient.sh
+++ b/awg/removeclient.sh
@@ -12,7 +12,8 @@ CLIENT_PUBLIC_KEY="$2"
WG_CONFIG_FILE="$3"
DOCKER_CONTAINER="$4"
-pwd=$(pwd)
+# Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
+pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/files"
SERVER_CONF_PATH="$pwd/files/server.conf"
@@ -74,8 +75,8 @@ docker cp "$SERVER_CONF_PATH" "$DOCKER_CONTAINER":"$WG_CONFIG_FILE"
docker exec -i "$DOCKER_CONTAINER" sh -c "wg-quick down '$WG_CONFIG_FILE' && wg-quick up '$WG_CONFIG_FILE'"
-rm -f "users/$CLIENT_NAME/$CLIENT_NAME.conf"
-rmdir "users/$CLIENT_NAME" 2>/dev/null || true
+rm -f "$pwd/users/$CLIENT_NAME/$CLIENT_NAME.conf"
+rmdir "$pwd/users/$CLIENT_NAME" 2>/dev/null || true
CLIENTS_TABLE_PATH="$pwd/files/clientsTable"
docker exec -i "$DOCKER_CONTAINER" cat /opt/amnezia/awg/clientsTable > "$CLIENTS_TABLE_PATH" || echo "[]" > "$CLIENTS_TABLE_PATH"
diff --git a/awg/settings.py b/awg/settings.py
new file mode 100644
index 0000000..1ae16a1
--- /dev/null
+++ b/awg/settings.py
@@ -0,0 +1,103 @@
+"""Загрузка и валидация настроек бота.
+
+Значения берутся из files/config.json, любое из них можно переопределить
+переменной окружения — так секреты (токен бота, токен платёжного провайдера)
+не обязаны лежать в репозитории.
+"""
+import logging
+from dataclasses import dataclass, field
+import os
+
+import config
+import db
+
+logger = logging.getLogger(__name__)
+
+# Telegram не принимает счета меньше этой суммы в рублях.
+MIN_INVOICE_RUB = 60.0
+
+
+def _env_or(cfg, key, env_name):
+ value = os.environ.get(env_name)
+ if value:
+ return value
+ return cfg.get(key)
+
+
+def _as_id_set(raw):
+ result = set()
+ for item in raw or []:
+ try:
+ result.add(int(item))
+ except (TypeError, ValueError):
+ logger.warning(f"Некорректный Telegram ID в конфигурации: {item!r} — пропущен.")
+ return result
+
+
+@dataclass
+class Settings:
+ bot_token: str
+ wg_config_file: str
+ docker_container: str
+ endpoint: str
+ admin_ids: set = field(default_factory=set)
+ moderator_ids: set = field(default_factory=set)
+ pricing: dict = field(default_factory=lambda: dict(config.DEFAULT_PRICING))
+ provider_token: str = ''
+ currency: str = 'RUB'
+ receipt: dict = field(default_factory=dict)
+
+ @property
+ def payments_enabled(self):
+ """Оплата доступна, только если задан токен платёжного провайдера."""
+ return bool(self.provider_token)
+
+ def price(self, period):
+ """Цена периода подписки с запасным значением по умолчанию."""
+ return float(self.pricing.get(period, config.DEFAULT_PRICING.get(period, 0.0)))
+
+ def is_admin(self, user_id):
+ return user_id in self.admin_ids
+
+ def is_staff(self, user_id):
+ return user_id in self.admin_ids or user_id in self.moderator_ids
+
+
+def load():
+ """Читает настройки, дополняет их окружением и проверяет обязательные поля."""
+ cfg = db.get_config()
+
+ settings = Settings(
+ bot_token=_env_or(cfg, 'bot_token', 'AWG_BOT_TOKEN') or '',
+ wg_config_file=_env_or(cfg, 'wg_config_file', 'AWG_WG_CONFIG_FILE') or '',
+ docker_container=_env_or(cfg, 'docker_container', 'AWG_DOCKER_CONTAINER') or '',
+ endpoint=_env_or(cfg, 'endpoint', 'AWG_ENDPOINT') or '',
+ admin_ids=_as_id_set(cfg.get('admin_ids')),
+ moderator_ids=_as_id_set(cfg.get('moderator_ids')),
+ pricing={**config.DEFAULT_PRICING, **(cfg.get('pricing') or {})},
+ provider_token=_env_or(cfg, 'payment_provider_token', 'AWG_PAYMENT_PROVIDER_TOKEN') or '',
+ currency=_env_or(cfg, 'payment_currency', 'AWG_PAYMENT_CURRENCY') or 'RUB',
+ receipt=cfg.get('payment_receipt') or {},
+ )
+
+ missing = [
+ name for name, value in (
+ ('bot_token', settings.bot_token),
+ ('wg_config_file', settings.wg_config_file),
+ ('docker_container', settings.docker_container),
+ ('endpoint', settings.endpoint),
+ ) if not value
+ ]
+ if missing:
+ raise ValueError(
+ f"В {config.CONFIG_FILE} не заданы обязательные настройки: {', '.join(missing)}."
+ )
+ if not settings.admin_ids:
+ raise ValueError(f"В {config.CONFIG_FILE} не задан ни один admin_ids.")
+
+ if not settings.payments_enabled:
+ logger.warning(
+ "payment_provider_token не задан — оплата отключена, ключи выдаются только "
+ "по промокодам. Получите токен у @BotFather (Payments -> ЮKassa)."
+ )
+ return settings
diff --git a/awg/vpn.py b/awg/vpn.py
new file mode 100644
index 0000000..dd79f35
--- /dev/null
+++ b/awg/vpn.py
@@ -0,0 +1,102 @@
+"""Операции с клиентами VPN: создание, удаление, выдача подписок.
+
+Все блокирующие вызовы (docker, скрипты, DNS) выполняются в отдельном потоке,
+чтобы не блокировать цикл событий бота.
+"""
+import asyncio
+import logging
+import shutil
+import uuid
+from datetime import datetime, timedelta
+
+import pytz
+
+import awg_decode
+import config
+import db
+
+logger = logging.getLogger(__name__)
+
+
+def client_conf_path(username):
+ """Путь к файлу конфигурации клиента."""
+ return config.USERS_DIR / username / f'{username}.conf'
+
+
+def _encode_conf(conf_text):
+ return awg_decode.encode(awg_decode.process_conf_data(conf_text))
+
+
+async def generate_vpn_key(conf_path):
+ """Преобразует конфиг клиента в ссылку vpn:// для приложения AmneziaVPN."""
+ try:
+ conf_text = conf_path.read_text(encoding='utf-8')
+ # process_conf_data резолвит DNS, поэтому выносим в поток.
+ return await asyncio.to_thread(_encode_conf, conf_text)
+ except Exception as e:
+ logger.error(f"Ошибка генерации vpn:// для {conf_path}: {e}")
+ return ''
+
+
+def make_username(telegram_id):
+ """Генерирует уникальное имя клиента для пользователя Telegram."""
+ return f'user_{telegram_id}_{uuid.uuid4().hex[:8]}'
+
+
+async def create_client(username, settings):
+ """Создаёт клиента WireGuard."""
+ return await asyncio.to_thread(
+ db.root_add, username, settings.endpoint,
+ settings.wg_config_file, settings.docker_container,
+ )
+
+
+async def delete_client(username, settings):
+ """Удаляет клиента WireGuard и все связанные с ним данные."""
+ removed = await asyncio.to_thread(
+ db.deactive_user_db, username,
+ settings.wg_config_file, settings.docker_container,
+ )
+ if not removed:
+ return False
+ shutil.rmtree(config.USERS_DIR / username, ignore_errors=True)
+ db.remove_user_expiration(username)
+ db.remove_user_telegram_id(username)
+ return True
+
+
+async def active_clients(settings):
+ """Возвращает {имя клиента: время последнего handshake}."""
+ return await asyncio.to_thread(
+ db.get_active_list, settings.wg_config_file, settings.docker_container,
+ )
+
+
+def expiration_for(period, since=None):
+ """Вычисляет дату окончания подписки для периода."""
+ months = config.PERIODS.get(period, 1)
+ return (since or datetime.now(pytz.utc)) + timedelta(days=30 * months)
+
+
+async def issue_subscription(telegram_id, period, settings):
+ """Создаёт клиента и выдаёт ему подписку. Возвращает имя клиента или None."""
+ username = make_username(telegram_id)
+ if not await create_client(username, settings):
+ return None
+ db.set_user_expiration(username, expiration_for(period))
+ db.set_user_telegram_id(username, telegram_id)
+ return username
+
+
+def extend_subscription(username, period):
+ """Продлевает подписку клиента, отсчитывая от текущей даты окончания.
+
+ Если подписка ещё активна, время не сгорает — новый период добавляется
+ к остатку.
+ """
+ current = db.get_user_expiration(username)
+ now = datetime.now(pytz.utc)
+ since = current if current and current > now else now
+ expiration = expiration_for(period, since=since)
+ db.set_user_expiration(username, expiration)
+ return expiration
diff --git a/awg/wg.py b/awg/wg.py
deleted file mode 100644
index ab112c9..0000000
--- a/awg/wg.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import subprocess
-import asyncio
-import os
-
-def generate_key():
- """Генерирует пару ключей WireGuard (приватный и публичный)."""
- private_key = subprocess.check_output(["wg", "genkey"]).decode().strip()
- public_key = subprocess.check_output(["wg", "pubkey"], input=private_key.encode()).decode().strip()
- return private_key, public_key
-
-def allocate_ip():
- """Выделяет уникальный IP-адрес из подсети 10.0.0.0/24."""
- conf_path = "/root/amnezia-bot/awg/files/wg0.conf"
- used_ips = []
-
- try:
- with open(conf_path, "r") as f:
- for line in f:
- if "AllowedIPs" in line:
- ip = line.split("=")[1].strip().split("/")[0]
- used_ips.append(ip)
- except FileNotFoundError:
- pass
-
- for i in range(2, 255): # Начинаем с 10.0.0.2
- ip = f"10.0.0.{i}"
- if ip not in used_ips:
- return ip
- raise Exception("Нет доступных IP-адресов.")
-
-async def generate_vpn_key(conf_path: str) -> str:
- """Преобразует .conf в формат vpn:// с помощью awg-decode.py."""
- process = await asyncio.create_subprocess_exec(
- 'python3.11', '/root/amnezia-bot/awg/awg-decode.py', '--encode', conf_path,
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
- if process.returncode == 0 and stdout.decode().startswith('vpn://'):
- return stdout.decode().strip()
- else:
- print(f"Ошибка генерации vpn://: {stderr.decode()}")
- return ""
-
-def add_peer(public_key, ip_address):
- """Добавляет новый пир в wg0.conf и синхронизирует с Docker."""
- conf_path = "/root/amnezia-bot/awg/files/wg0.conf"
- with open(conf_path, "a") as f:
- f.write(f"\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {ip_address}/32\n")
- subprocess.run(["docker", "exec", "amnezia-awg", "wg", "syncconf", "wg0", "/config/wg0.conf"])
diff --git a/handlers/add_client.py b/handlers/add_client.py
deleted file mode 100644
index d3a94ea..0000000
--- a/handlers/add_client.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from aiogram import types
-from aiogram.dispatcher import FSMContext
-from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
-from aiogram.dispatcher.filters.state import State, StatesGroup
-import subprocess
-from loader import dp, bot # Убедись, что loader.py существует
-
-class AddClient(StatesGroup):
- protocol = State()
- username = State()
-
-@dp.message_handler(commands=["add_client"])
-async def cmd_add_client(message: types.Message):
- keyboard = InlineKeyboardMarkup()
- keyboard.add(
- InlineKeyboardButton("WireGuard", callback_data="proto_wg"),
- InlineKeyboardButton("XRay", callback_data="proto_xray")
- )
- await message.answer("Выберите протокол для нового клиента:", reply_markup=keyboard)
- await AddClient.protocol.set()
-
-@dp.callback_query_handler(lambda c: c.data.startswith("proto_"), state=AddClient.protocol)
-async def process_protocol_choice(callback_query: types.CallbackQuery, state: FSMContext):
- protocol = callback_query.data.replace("proto_", "")
- await state.update_data(protocol=protocol)
- await bot.send_message(callback_query.from_user.id, f"Выбран протокол: {protocol.upper()}\nВведите имя клиента:")
- await AddClient.username.set()
-
-@dp.message_handler(state=AddClient.username)
-async def process_username(message: types.Message, state: FSMContext):
- data = await state.get_data()
- username = message.text.strip()
- protocol = data.get("protocol", "wg")
-
- try:
- subprocess.run(
- ["amnezia", "client", "add", "--proto", protocol, "--name", username],
- check=True
- )
- await message.answer(f"✅ Клиент `{username}` с протоколом `{protocol.upper()}` успешно создан.", parse_mode="Markdown")
- except subprocess.CalledProcessError as e:
- await message.answer(f"❌ Ошибка при создании клиента:\n```\n{str(e)}\n```", parse_mode="Markdown")
-
- await state.finish()
diff --git a/install.sh b/install.sh
index 46afa41..86823aa 100644
--- a/install.sh
+++ b/install.sh
@@ -1,10 +1,31 @@
#!/bin/bash
-# Конфигурация
-SERVICE_NAME="awg_bot"
-REPO_URL="https://github.com/stevefoxru/amnezia-bot.git"
-REPO_API="https://api.github.com/repos/stevefoxru/amnezia-bot"
-LOCAL_VERSION_FILE="/root/amnezia-bot/.version"
+# Конфигурация. Любое значение можно переопределить переменной окружения.
+SERVICE_NAME="${AWG_SERVICE_NAME:-awg_bot}"
+
+# Каталог установки бота (по умолчанию — как в прежних версиях).
+BASE_DIR="${AWG_BOT_HOME:-/root/amnezia-bot}"
+PARENT_DIR="$(dirname "$BASE_DIR")"
+BOT_DIRNAME="$(basename "$BASE_DIR")"
+
+# Репозиторий: берём origin уже установленной копии, иначе значение по умолчанию.
+DEFAULT_REPO_URL="https://github.com/viktor138irk/amnezia-bot.git"
+if [[ -z "$AWG_REPO_URL" && -d "$BASE_DIR/.git" ]]; then
+ AWG_REPO_URL="$(git -C "$BASE_DIR" remote get-url origin 2>/dev/null)"
+fi
+REPO_URL="${AWG_REPO_URL:-$DEFAULT_REPO_URL}"
+REPO_BRANCH="${AWG_REPO_BRANCH:-main}"
+# https://github.com/