From 11b52cf5ad8c918b060efa96715b30310e75e0b0 Mon Sep 17 00:00:00 2001 From: viktor138irk Date: Fri, 4 Sep 2026 14:44:26 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5=D0=B9,=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=85=D0=B0=D1=80=D0=B4?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B0,=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Библиотеки: - миграция с aiogram 2.25.2 (EOL) на aiogram 3.31: aiohttp обновился с 3.8.6 до 3.14.3, так как aiogram 2 жёстко требовал aiohttp<3.9 - самодельный словарь состояний заменён на FSM aiogram (MemoryStorage); это убирает падение на user_state.startswith(), когда состояния нет - из requirements убраны неиспользуемые пакеты (Babel 2.9.1, humanize, aiofiles, yookassa, YooMoney); оставлены только прямые зависимости Хардкод: - пути вычисляются от расположения файлов бота (awg/config.py), больше нет привязки к /root/amnezia-bot и к запуску из конкретного каталога - вместо 'python3.11' используется sys.executable; awg-decode.py переименован в awg_decode.py и импортируется напрямую вместо запуска через subprocess - newclient.sh и removeclient.sh определяют каталог по своему расположению, а не по cwd - install.sh: каталог, репозиторий, ветка и Python настраиваются переменными окружения, зависимости ставятся из requirements.txt - секреты можно задавать переменными окружения вместо config.json Оплата (встроенные платежи Telegram, провайдер ЮKassa): - счёт, pre_checkout_query и successful_payment; публичный HTTPS-адрес и вебхук не нужны - обработчик платежей вынесен в отдельный роутер и подключён первым: successful_payment приходит без текста и иначе перехватывался бы обработчиком, ожидающим ввода в FSM - выдача ключа идемпотентна по id платежа, повторная доставка апдейта не создаёт второй ключ - если после оплаты ключ создать не удалось, уведомляются администраторы - промокоды разделены на validate/consume: использование засчитывается только после фактической выдачи ключа, а не при проверке кода - поддержаны скидочные промокоды, уменьшающие сумму счёта - опциональные чеки по 54-ФЗ через provider_data Исправленные ошибки: - db.root_add и db.deactive_user_db передавали скриптам 2 аргумента вместо 4, из-за чего создание и удаление клиентов всегда падало; публичный ключ для удаления теперь вычисляется из конфига клиента - в create_backup не был импортирован zipfile (NameError при бэкапе) - планировщик запускался без задач, подписки никогда не истекали; добавлена ежечасная проверка с отключением доступа и уведомлением - callback_data вида renew_period_<имя>_<период> разбирался неверно для имён с подчёркиваниями; разделителем стало двоеточие - статус клиента брался из status.json, который никто не создавал; теперь используется wg show latest-handshakes - продление подписки сбрасывало остаток, теперь период добавляется к нему - Markdown-разметка ломалась на именах с подчёркиваниями; используется HTML с экранированием - vpn:// ключ уходит отдельным сообщением, а не в подписи к файлу, где мог не поместиться в лимит 1024 символа - блокирующие вызовы docker и скриптов вынесены в отдельные потоки - при удалении клиента связь с Telegram ID удаляется, а не пишется None Удалён мёртвый код: awg/wg.py (нигде не импортировался, содержал подсеть 10.0.0.0/24 вместо используемой 10.8.1.0/24) и handlers/add_client.py (импортировал несуществующий модуль loader). --- .gitignore | 11 + README.md | 87 +- awg/{awg-decode.py => awg_decode.py} | 9 +- awg/bot_manager.py | 1923 ++++++++++++-------------- awg/config.py | 65 + awg/db.py | 369 +++-- awg/files/config.json.example | 26 + awg/keyboards.py | 177 +++ awg/newclient.sh | 3 +- awg/payments.py | 94 ++ awg/removeclient.sh | 7 +- awg/settings.py | 103 ++ awg/vpn.py | 102 ++ awg/wg.py | 49 - handlers/add_client.py | 44 - install.sh | 86 +- requirements.txt | 26 +- 17 files changed, 1904 insertions(+), 1277 deletions(-) create mode 100644 .gitignore rename awg/{awg-decode.py => awg_decode.py} (94%) create mode 100644 awg/config.py create mode 100644 awg/files/config.json.example create mode 100644 awg/keyboards.py mode change 100644 => 100755 awg/newclient.sh create mode 100644 awg/payments.py mode change 100644 => 100755 awg/removeclient.sh create mode 100644 awg/settings.py create mode 100644 awg/vpn.py delete mode 100644 awg/wg.py delete mode 100644 handlers/add_client.py 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 ") - -@dp.message_handler() -async def handle_messages(message: types.Message): - global PRICING - user_id = message.from_user.id - user_state = user_main_messages.get(user_id, {}).get('state') - - if user_state == 'waiting_for_user_name': - user_name = message.text.strip() - if not re.match(r'^[a-zA-Z0-9_-]+$', user_name): - await message.reply("Имя может содержать только буквы, цифры, - и _.") - return - success = db.root_add(user_name, ipv6=False) - if success: - conf_path = os.path.join('users', user_name, f'{user_name}.conf') - if os.path.exists(conf_path): - vpn_key = await generate_vpn_key(conf_path) - caption = f"Конфигурация для {user_name}:\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) - 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 - } - elif user_state == 'waiting_for_admin_id' and user_id in admins: - try: - new_admin_id = int(message.text.strip()) - if new_admin_id not in admins: - db.add_admin(new_admin_id) - admins.append(new_admin_id) - await message.reply(f"Админ {new_admin_id} добавлен.") - await bot.send_message(new_admin_id, "Вы назначены администратором!") - 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 - } - except: - await message.reply("Введите корректный Telegram ID.") - elif user_state == 'waiting_for_promocode': - promocode = message.text.strip() - promocode_data = db.apply_promocode(promocode) - if promocode_data: - subscription_period = promocode_data.get('subscription_period') - if subscription_period: - success = await issue_vpn_key(user_id, subscription_period) - if success: - await message.reply(f"Промокод активирован! VPN ключ на {subscription_period.replace('_', ' ')} выдан.") - else: - await message.reply("Ошибка при выдаче ключа. Обратитесь к администратору.") - else: - await message.reply("Промокод не предоставляет ключ.") - else: - await message.reply("Неверный или истёкший промокод.") - 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 - } - elif user_state == 'waiting_for_new_promocode' and user_id in admins: - try: - parts = message.text.strip().split() - if len(parts) != 5: - raise ValueError("Неверный формат") - code, discount, days_valid, max_uses, subscription_period = parts - discount = float(discount) - days_valid = int(days_valid) - max_uses = int(max_uses) if max_uses.lower() != 'none' else None - if subscription_period not in ['none', '1_month', '3_months', '6_months', '12_months']: - raise ValueError("Неверный период подписки") - subscription_period = None if subscription_period.lower() == 'none' else subscription_period - 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, subscription_period): - await message.reply( - f"Промокод {code} добавлен: скидка {discount}%, действует {days_valid} дней, " - f"макс. использований: {max_uses or 'неограничено'}, период подписки: {subscription_period or 'нет'}" - ) - else: - await message.reply("Промокод уже существует.") - except: - await message.reply( - "Формат: <код> <скидка%> <дней_действия> <макс_использований|none> <период_подписки|none>\n" - "Пример: PROMO1 10 30 none 1_month" - ) - 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 - } - elif user_state.startswith('waiting_for_price_') and user_id in admins: - period = user_state.split('waiting_for_price_')[1] - try: - price = float(message.text.strip()) - if price <= 0: - raise ValueError("Цена должна быть положительной.") - db.set_pricing(period, price) - PRICING[period] = price - await message.reply(f"Цена для {period.replace('_', ' ')} обновлена: ₽{price:.2f}") - except: - await message.reply("Введите корректное число (например, 1000.00).") - return - sent_message = await message.answer("Настройки цен:", reply_markup=get_pricing_settings_menu()) - user_main_messages[user_id] = { - 'chat_id': sent_message.chat.id, - 'message_id': sent_message.message_id, - 'state': None - } - elif user_state.startswith('waiting_for_custom_date_') and user_id in admins: - username = user_state.split('waiting_for_custom_date_')[1] - try: - expiration = datetime.strptime(message.text.strip(), '%d-%m-%Y').replace(tzinfo=pytz.utc) - if expiration < datetime.now(pytz.utc): - await message.reply("Дата должна быть в будущем.") - return - db.set_user_expiration(username, expiration, "Неограниченно") - await message.reply(f"Подписка для {username} продлена до {expiration.strftime('%d-%m-%Y')}.") - except: - await message.reply("Введите дату в формате ДД-ММ-ГГГГ (например, 31-12-2025).") - return - 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 - } - -@dp.callback_query_handler(lambda c: c.data == "settings") -async def settings_menu_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) + parts = message.text.split() + if len(parts) != 2 or not parts[1].lstrip('-').isdigit(): + await message.answer('Формат: /add_admin <user_id>') 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="Настройки:", - reply_markup=get_settings_menu() - ) - 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 == "pricing_settings") -async def pricing_settings_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) + new_admin_id = int(parts[1]) + if new_admin_id in SETTINGS.admin_ids: + await message.answer(f'{new_admin_id} уже администратор.') return + db.add_admin(new_admin_id) + SETTINGS.admin_ids.add(new_admin_id) + await message.answer(f'Админ {new_admin_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_pricing_settings_menu() - ) - 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('set_price_')) -async def set_price_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 - period = callback_query.data.split('set_price_')[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=f"Введите новую цену для {period.replace('_', ' ')} в рублях (например, 1000.00):", - reply_markup=InlineKeyboardMarkup().add(InlineKeyboardButton("⬅️ Назад", callback_data="pricing_settings")) - ) - user_main_messages[user_id] = { - 'chat_id': sent_message.chat.id, - 'message_id': sent_message.message_id, - 'state': f'waiting_for_price_{period}' - } - await callback_query.answer() - -@dp.callback_query_handler(lambda c: c.data == "add_user") -async def prompt_for_user_name(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) - 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="Введите имя пользователя:", - 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_user_name' - } - await callback_query.answer() - -@dp.callback_query_handler(lambda c: c.data == "add_admin") -async def prompt_for_admin_id(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: - 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="Введите Telegram ID нового админа:", - 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_admin_id' - } - await callback_query.answer() - -@dp.callback_query_handler(lambda c: c.data.startswith('client_')) -async def client_selected_callback(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) - return - - try: - username = callback_query.data.split('client_')[1] - clients = db.get_client_list() - client_info = next((c for c in clients if c[0] == username), None) - if not client_info: - await callback_query.answer("Пользователь не найден.", show_alert=True) - return - - status = "🔴 Офлайн" - expiration = db.get_user_expiration(username) - expiration_text = expiration.strftime("%Y-%m-%d %H:%M UTC") if expiration else "Не установлен" - - active_clients = db.get_active_list() - active_info = next((ac for ac in active_clients if ac[0] == username), None) - if active_info and active_info[1] and active_info[1].lower() not in ['never', 'нет данных', '-']: - try: - last_handshake = datetime.strptime(active_info[1], "%Y-%m-%d %H:%M:%S") - status = "🟢 Онлайн" if (datetime.now(pytz.utc) - last_handshake).total_seconds() <= 60 else "❌ Офлайн" - except: - pass - - text = ( - f"📧 *Имя:* {username}\n" - f"🌐 *Статус:* {status}\n" - f"⏰ *Срок действия:* {expiration_text}" - ) - - keyboard = InlineKeyboardMarkup(row_width=2).add( - InlineKeyboardButton("🗑️ Удалить", callback_data=f"delete_user_{username}"), - InlineKeyboardButton("🔄 Продлить", callback_data=f"renew_user_{username}"), - InlineKeyboardButton("⬅️ Назад", callback_data="list_users"), - 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, - parse_mode="Markdown", - 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() - + await bot.send_message(new_admin_id, 'Вы назначены администратором!') except Exception as e: - logger.error(f"Ошибка в client_selected_callback: {str(e)}") - sent_message = await bot.send_message( - chat_id=callback_query.message.chat.id, - text=f"Ошибка при загрузке профиля: {str(e)}", - reply_markup=InlineKeyboardMarkup().add( - InlineKeyboardButton("⬅️ Назад", callback_data="list_users"), - InlineKeyboardButton("🏠 Домой", callback_data="home") - ) - ) - user_main_messages[user_id] = { - 'chat_id': sent_message.chat.id, - 'message_id': sent_message.message_id, - 'state': None - } - await callback_query.answer("Ошибка на сервере.", show_alert=True) + logger.warning(f"Не удалось уведомить нового админа {new_admin_id}: {e}") -@dp.callback_query_handler(lambda c: c.data == "list_users") -async def list_users_callback(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 == 'home') +async def return_home(callback: CallbackQuery, state: FSMContext): + await show_main_menu(callback, state) + await callback.answer() + + +@router.callback_query(F.data == 'settings') +async def settings_menu(callback: CallbackQuery, state: FSMContext): + if not SETTINGS.is_admin(callback.from_user.id): + await deny(callback) return + await state.clear() + await show(callback, 'Настройки:', kb.settings_menu()) + await callback.answer() - try: - clients = db.get_client_list() - if not clients: - 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") - ) - ) - user_main_messages[user_id] = { - 'chat_id': sent_message.chat.id, - 'message_id': sent_message.message_id, - 'state': None - } - await callback_query.answer() - return - keyboard = InlineKeyboardMarkup(row_width=2) - active_clients = {client[0]: client[1] for client in db.get_active_list()} - for client in clients: - username = client[0] - last_handshake = active_clients.get(username) - status = "❌" if not last_handshake or last_handshake.lower() in ['never', 'нет данных', '-'] else "🟢" - button_text = f"{status} {username}" - keyboard.insert(InlineKeyboardButton(button_text, callback_data=f"client_{username}")) +# --- Управление клиентами --------------------------------------------------- - 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() - - except Exception as e: - logger.error(f"Ошибка в list_users_callback: {str(e)}") - sent_message = await bot.send_message( - chat_id=callback_query.message.chat.id, - text=f"Ошибка: {str(e)}", - 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': None - } - await callback_query.answer("Ошибка на сервере.", show_alert=True) - -@dp.callback_query_handler(lambda c: c.data == "list_admins") -async def list_admins_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 == 'add_user') +async def prompt_for_user_name(callback: CallbackQuery, state: FSMContext): + if not SETTINGS.is_staff(callback.from_user.id): + await deny(callback) return - keyboard = InlineKeyboardMarkup(row_width=2) - for admin_id in admins: - keyboard.insert(InlineKeyboardButton(f"🗑️ Удалить {admin_id}", callback_data=f"remove_admin_{admin_id}")) - keyboard.add(InlineKeyboardButton("⬅️ Назад", callback_data="settings")) - 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=f"Администраторы:\n" + "\n".join(f"- {admin_id}" for admin_id in admins), - reply_markup=keyboard + await state.set_state(Form.user_name) + await show( + callback, + 'Введите имя пользователя (латиница, цифры, дефис и подчёркивание):', + kb.home_only(), ) - 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() -@dp.callback_query_handler(lambda c: c.data.startswith('remove_admin_')) -async def remove_admin_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 - admin_id = int(callback_query.data.split('_')[2]) - if admin_id not in admins or len(admins) <= 1: - await callback_query.answer("Нельзя удалить последнего админа или несуществующего.", show_alert=True) - return - db.remove_admin(admin_id) - admins.remove(admin_id) - await bot.send_message(admin_id, "Вы удалены из администраторов.") - await list_admins_callback(callback_query) -@dp.callback_query_handler(lambda c: c.data.startswith('delete_user_')) -async def client_delete_callback(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.message(Form.user_name) +async def create_user(message: Message, state: FSMContext): + if not SETTINGS.is_staff(message.from_user.id): + await state.clear() return - username = callback_query.data.split('delete_user_')[1] - try: - if db.deactive_user_db(username): - shutil.rmtree(os.path.join('users', username), ignore_errors=True) - db.remove_user_expiration(username) - db.set_user_telegram_id(username, None) - logger.info(f"Пользователь {username} успешно удалён.") - text = f"Пользователь **{username}** удалён." - else: - logger.error(f"Не удалось удалить пользователя {username} через db.deactive_user_db.") - text = f"Не удалось удалить **{username}**. Проверьте логи." - except Exception as e: - logger.error(f"Ошибка при удалении пользователя {username}: {str(e)}") - text = f"Ошибка при удалении **{username}**: {str(e)}" - try: - await bot.delete_message( - chat_id=callback_query.message.chat.id, - message_id=callback_query.message.message_id + username = (message.text or '').strip() + if not config.USERNAME_RE.match(username): + await message.reply('Имя может содержать только латинские буквы, цифры, - и _.') + return + if vpn.client_conf_path(username).exists(): + await message.reply('Клиент с таким именем уже существует.') + return + + status = await message.answer('Создаю клиента, это займёт несколько секунд...') + created = await vpn.create_client(username, SETTINGS) + await status.delete() + + if created: + db.set_user_telegram_id(username, message.from_user.id) + await send_client_config(message.chat.id, username) + else: + await message.answer( + 'Не удалось создать клиента. Проверьте логи бота и доступность контейнера ' + f'{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//.git -> https://api.github.com/repos// +REPO_API="https://api.github.com/repos/$(echo "$REPO_URL" | sed -E 's#^.*github\.com[:/]##; s#\.git$##')" +LOCAL_VERSION_FILE="$BASE_DIR/.version" + +# Python: нужен 3.10+, конкретная минорная версия не важна. +PYTHON_BIN="${AWG_PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + for candidate in python3.13 python3.12 python3.11 python3.10 python3; do + if command -v "$candidate" &>/dev/null; then PYTHON_BIN="$candidate"; break; fi + done +fi # Цвета для вывода GREEN=$'\033[0;32m' @@ -53,10 +74,10 @@ run_with_spinner() { # Функция проверки обновлений на GitHub check_github_updates() { local current_sha local_sha latest_sha auto_mode="$1" - cd /root/amnezia-bot || { echo -e "${RED}Каталог amnezia-bot не найден${NC}"; return 1; } + cd "$BASE_DIR" || { echo -e "${RED}Каталог $BASE_DIR не найден${NC}"; return 1; } echo -e "${YELLOW}Текущая директория: $(pwd)${NC}" - echo -e "${YELLOW}Содержимое /root/amnezia-bot:${NC}" + echo -e "${YELLOW}Содержимое $BASE_DIR:${NC}" ls -la # Получение текущего SHA коммита @@ -64,7 +85,7 @@ check_github_updates() { # Получение последнего коммита через GitHub API if command -v curl &>/dev/null; then - latest_sha=$(curl -s "$REPO_API/commits/main" | jq -r '.sha' 2>/dev/null) + latest_sha=$(curl -s "$REPO_API/commits/$REPO_BRANCH" | jq -r '.sha' 2>/dev/null) [[ -z "$latest_sha" ]] && { echo -e "${RED}Не удалось получить данные с GitHub${NC}"; cd ..; return 1; } else echo -e "${RED}curl не установлен${NC}"; cd ..; return 1 @@ -83,14 +104,14 @@ check_github_updates() { echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}" run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd" fi - run_with_spinner "Обновление репозитория" "git pull" + run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH" echo "$latest_sha" > "$LOCAL_VERSION_FILE" check_script_update # Проверка и создание виртуального окружения, если отсутствует if [[ ! -d "myenv" ]]; then - run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv" + run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv" fi - run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install aiogram==2.25.1 aiohttp==3.8.6 apscheduler==3.10.4 humanize==4.9.0 pytz==2023.3.post1 && deactivate" + run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate" run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME" else echo -ne "${BLUE}1) Установить 2) Отменить: ${NC}"; read choice @@ -99,14 +120,14 @@ check_github_updates() { echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}" run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd" fi - run_with_spinner "Обновление репозитория" "git pull" + run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH" echo "$latest_sha" > "$LOCAL_VERSION_FILE" check_script_update # Проверка и создание виртуального окружения, если отсутствует if [[ ! -d "myenv" ]]; then - run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv" + run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv" fi - run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install aiogram==2.25.1 aiohttp==3.8.6 apscheduler==3.10.4 humanize==4.9.0 pytz==2023.3.post1 && deactivate" + run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate" run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME" else echo -e "${YELLOW}Обновление отменено${NC}" @@ -118,8 +139,8 @@ check_github_updates() { # Проверка обновления самого скрипта check_script_update() { local temp_script=$(mktemp) - if [[ -f "/root/amnezia-bot/install.sh" ]]; then - cp "/root/amnezia-bot/install.sh" "$temp_script" + if [[ -f "$BASE_DIR/install.sh" ]]; then + cp "$BASE_DIR/install.sh" "$temp_script" if ! cmp -s "$SCRIPT_PATH" "$temp_script"; then echo -e "${YELLOW}Обнаружено обновление скрипта install.sh${NC}" run_with_spinner "Обновление скрипта" "mv $temp_script $SCRIPT_PATH && chmod +x $SCRIPT_PATH" @@ -130,8 +151,8 @@ check_script_update() { fi else echo -e "${YELLOW}Скрипт install.sh не найден в репозитории, копируем локальную версию${NC}" - cp "$SCRIPT_PATH" "/root/amnezia-bot/install.sh" - chmod +x "/root/amnezia-bot/install.sh" + cp "$SCRIPT_PATH" "$BASE_DIR/install.sh" + chmod +x "$BASE_DIR/install.sh" fi } @@ -147,7 +168,11 @@ check_updates() { fi # Проверка остальных зависимостей - for cmd in git curl python3.11; do + if [[ -z "$PYTHON_BIN" ]]; then + echo -e "${RED}Python 3 не найден. Установите python3 (3.10 или новее).${NC}" + exit 1 + fi + for cmd in git curl "$PYTHON_BIN"; do if ! command -v "$cmd" &>/dev/null; then echo -e "${RED}$cmd не установлен. Установите $cmd для продолжения.${NC}" exit 1 @@ -161,22 +186,23 @@ check_updates() { fi # Проверка и клонирование репозитория, если он отсутствует - if [[ ! -d "/root/amnezia-bot/.git" ]]; then - echo -e "${YELLOW}Репозиторий не найден. Проверяем /root/amnezia-bot...${NC}" - echo -e "${YELLOW}Содержимое /root:${NC}" - ls -la /root - if [[ -d "/root/amnezia-bot" ]]; then - echo -e "${YELLOW}Директория /root/amnezia-bot существует, но не является git-репозиторием. Удаляем её...${NC}" - rm -rf /root/amnezia-bot || error_exit "Не удалось удалить поврежденную директорию /root/amnezia-bot" + if [[ ! -d "$BASE_DIR/.git" ]]; then + echo -e "${YELLOW}Репозиторий не найден. Проверяем $BASE_DIR...${NC}" + echo -e "${YELLOW}Содержимое $PARENT_DIR:${NC}" + ls -la "$PARENT_DIR" + if [[ -d "$BASE_DIR" ]]; then + echo -e "${YELLOW}Директория $BASE_DIR существует, но не является git-репозиторием. Удаляем её...${NC}" + rm -rf $BASE_DIR || error_exit "Не удалось удалить поврежденную директорию $BASE_DIR" fi echo -e "${YELLOW}Клонируем репозиторий...${NC}" - cd /root || error_exit "Не удалось перейти в /root" - run_with_spinner "Клонирование репозитория" "git clone $REPO_URL" - cd amnezia-bot || error_exit "Не удалось перейти в каталог amnezia-bot" + mkdir -p "$PARENT_DIR" || error_exit "Не удалось создать $PARENT_DIR" + cd "$PARENT_DIR" || error_exit "Не удалось перейти в $PARENT_DIR" + run_with_spinner "Клонирование репозитория" "git clone -b $REPO_BRANCH $REPO_URL $BOT_DIRNAME" + cd "$BOT_DIRNAME" || error_exit "Не удалось перейти в каталог $BOT_DIRNAME" if [[ ! -d ".git" ]]; then error_exit "Клонирование репозитория не удалось. Проверьте доступ к $REPO_URL" fi - echo -e "${YELLOW}Содержимое /root/amnezia-bot после клонирования:${NC}" + echo -e "${YELLOW}Содержимое $BASE_DIR после клонирования:${NC}" ls -la fi check_github_updates "$1" diff --git a/requirements.txt b/requirements.txt index bcdae0c..2c0302d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,4 @@ -aiofiles==24.1.0 -aiogram==2.25.2 -aiohttp==3.8.6 -aiosignal==1.3.1 -APScheduler==3.10.4 -async-timeout==4.0.3 -attrs==24.2.0 -Babel==2.9.1 -certifi==2024.8.30 -charset-normalizer==3.4.0 -frozenlist==1.5.0 -humanize==4.11.0 -idna==3.10 -magic-filter==1.0.12 -multidict==6.1.0 -propcache==0.2.0 -pytz==2024.2 -six==1.16.0 -tzlocal==5.2 -yarl==1.17.1 -yookassa==2.4.0 -YooMoney==0.1.0 +# Прямые зависимости бота. Транзитивные (aiohttp и др.) подтягивает pip. +aiogram==3.31.0 +APScheduler==3.11.3 +pytz==2026.3.post1