Обновление зависимостей, устранение хардкода, оплата через Telegram

Библиотеки:
- миграция с 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).
Этот коммит содержится в:
2026-09-04 14:44:26 +00:00
родитель 90b00d971d
Коммит 11b52cf5ad
17 изменённых файлов: 1904 добавлений и 1277 удалений
+11
Просмотреть файл
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
myenv/
venv/
.version
# Рабочие данные и секреты бота
awg/files/*
!awg/files/config.json.example
awg/users/
backup_*.zip
+70 -15
Просмотреть файл
@@ -1,27 +1,32 @@
# AmneziaVPN Telegram Bot # 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. Используется библиотека `aiogram` версии 3.x.
Протестировано на Ubuntu, версии 20.04, 22.04, 24.04. Требуется Python 3.10 или новее. Протестировано на Ubuntu 20.04, 22.04, 24.04.
## Оглавление ## Оглавление
- [Возможности](#возможности) - [Возможности](#возможности)
- [Установка](#установка) - [Установка](#установка)
- [Настройка оплаты](#настройка-оплаты)
- [Конфигурация](#конфигурация)
- [Запуск](#запуск) - [Запуск](#запуск)
- [Заметки](#заметки) - [Заметки](#заметки)
- [Поддержка](#поддержка) - [Поддержка](#поддержка)
## Возможности ## Возможности
- Добавление администраторов и модераторов для упралением VPN - Роли: администраторы, модераторы и обычные пользователи
- Добавление клиентов - Добавление и удаление клиентов
- Удаление клиентов - Создание ключа в формате `vpn://` при генерации нового клиента (и при получении конфигурации), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client)
- Получение информации об IP-адресе клиента (берется из Endpoint, используется API ресурса [ip-api.com](http://ip-api.com)) - Статус клиента онлайн/офлайн по реальному времени последнего handshake
- Создание ключа в формате `vpn://` при генерации нового клиента (так же, при получении конфигурации клиента), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client) - Подписки на 1, 3, 6 и 12 месяцев с автоматическим отключением по истечении срока
- Продление подписки на период или до произвольной даты; остаток активной подписки не сгорает
- **Оплата подписки прямо в Telegram** (провайдер — ЮKassa), с чеками по 54-ФЗ при необходимости
- Промокоды двух видов: выдающие ключ бесплатно и дающие скидку при оплате
- Настраиваемые цены из интерфейса бота
- Создание резервной копии - Создание резервной копии
- Инструкции по работе с VPN
## Установка ## Установка
@@ -43,9 +48,60 @@
5. Загрузите и запустите скрипт `install.sh`, с помощью которого будет автоматически установлен бот, со всеми зависимостями, в том числе, в качестве системной службы (автозапуск): 5. Загрузите и запустите скрипт `install.sh`, с помощью которого будет автоматически установлен бот, со всеми зависимостями, в том числе, в качестве системной службы (автозапуск):
```bash ```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` для начала работы. 1. Добавьте бота в Telegram и отправьте команду `/start` или `/help` для начала работы.
@@ -54,11 +110,10 @@
Для обновления бота, необходимо запустить скрипт `install.sh`. В меню, необходимо выбрать пункт `Проверить обновления`. Для обновления бота, необходимо запустить скрипт `install.sh`. В меню, необходимо выбрать пункт `Проверить обновления`.
При создании резервной копии, в архив добавляется директория connections (создается и содержит в себе логи подключений клиентов), conf, png, и сам конфигурационный файл. При создании резервной копии, в архив добавляется директория `users` (конфигурации клиентов), `files` (настройки, промокоды, платежи, сроки подписок) и скрипты бота.
Подписки проверяются раз в час: по истечении срока клиент удаляется с сервера, а его владелец получает уведомление.
## Поддержка ## Поддержка
Поддержать разработчика можете следующими способами: Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/viktor138irk/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику.
Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/stevefoxru/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику.
+5 -2
Просмотреть файл
@@ -61,8 +61,7 @@ def process_conf_data(data):
print(f"Resolved DNS '{address}' to IP '{resolved_ip}'", file=sys.stderr) print(f"Resolved DNS '{address}' to IP '{resolved_ip}'", file=sys.stderr)
return f"{prefix}{resolved_ip}:{port}{suffix}" return f"{prefix}{resolved_ip}:{port}{suffix}"
else: else:
print(f"Error: Could not resolve DNS name '{address}'", file=sys.stderr) raise ValueError(f"Не удалось разрешить DNS-имя '{address}'")
sys.exit(1)
else: else:
return full_line return full_line
pattern = r'^(.*Endpoint\s*=\s*)([^\s:]+)(?::(\d+))(.*)$' pattern = r'^(.*Endpoint\s*=\s*)([^\s:]+)(?::(\d+))(.*)$'
@@ -107,7 +106,11 @@ def main():
print(f'Error reading file {args.input}: {e}') print(f'Error reading file {args.input}: {e}')
sys.exit(1) sys.exit(1)
try:
processed_data = process_conf_data(data) 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) encoded_string = encode(processed_data)
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+65
Просмотреть файл
@@ -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)
+262 -107
Просмотреть файл
@@ -1,165 +1,273 @@
"""Хранилище данных бота (JSON-файлы) и операции с клиентами WireGuard."""
import json import json
import os
import subprocess
import logging import logging
import re
import subprocess
from datetime import datetime from datetime import datetime
import pytz
import shutil
logging.basicConfig(level=logging.INFO) import pytz
import config
logger = logging.getLogger(__name__) 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): def load_json(file_path, default=None):
"""Загружает JSON-файл, возвращает default при ошибке или отсутствии файла.""" """Загружает JSON-файл, возвращает default при ошибке или отсутствии файла."""
try: try:
if os.path.exists(file_path): if file_path.exists():
with open(file_path, 'r') as f: with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except Exception as e: 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 {} return default if default is not None else {}
def save_json(file_path, data): def save_json(file_path, data):
"""Сохраняет данные в JSON-файл.""" """Сохраняет данные в JSON-файл (атомарно, через временный файл)."""
try: try:
os.makedirs(os.path.dirname(file_path), exist_ok=True) file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'w') as f: tmp_path = file_path.with_suffix(file_path.suffix + '.tmp')
json.dump(data, f, indent=4, default=str) 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 return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка сохранения {file_path}: {str(e)}") logger.error(f"Ошибка сохранения {file_path}: {e}")
return False return False
# --- Конфигурация -----------------------------------------------------------
def get_config(): def get_config():
"""Возвращает конфигурацию из config.json.""" """Возвращает конфигурацию из 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): def add_admin(admin_id):
"""Добавляет ID администратора в конфигурацию.""" """Добавляет ID администратора в конфигурацию."""
config = get_config() cfg = get_config()
admin_ids = config.get('admin_ids', []) admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
if str(admin_id) not in admin_ids: if str(admin_id) not in admin_ids:
admin_ids.append(str(admin_id)) admin_ids.append(str(admin_id))
config['admin_ids'] = admin_ids cfg['admin_ids'] = admin_ids
save_json(CONFIG_FILE, config) save_config(cfg)
def remove_admin(admin_id): def remove_admin(admin_id):
"""Удаляет ID администратора из конфигурации.""" """Удаляет ID администратора из конфигурации."""
config = get_config() cfg = get_config()
admin_ids = config.get('admin_ids', []) admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
admin_id_str = str(admin_id) if str(admin_id) in admin_ids:
if admin_id_str in admin_ids: admin_ids.remove(str(admin_id))
admin_ids.remove(admin_id_str) cfg['admin_ids'] = admin_ids
config['admin_ids'] = admin_ids save_config(cfg)
save_json(CONFIG_FILE, config)
def set_pricing(period, price): def set_pricing(period, price):
"""Устанавливает цену для указанного периода подписки.""" """Устанавливает цену для указанного периода подписки."""
config = get_config() cfg = get_config()
config['pricing'] = config.get('pricing', {}) pricing = cfg.get('pricing', dict(config.DEFAULT_PRICING))
config['pricing'][period] = price pricing[period] = price
save_json(CONFIG_FILE, config) 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: try:
cmd = ['./newclient.sh', name] conf = conf_path.read_text(encoding='utf-8')
if not ipv6: match = re.search(r'^PrivateKey\s*=\s*(\S+)', conf, re.MULTILINE)
cmd.append('--no-ipv6') if match:
process = subprocess.run(cmd, capture_output=True, text=True) 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:
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: if process.returncode == 0:
return True return True
logger.error(f"Ошибка добавления пользователя {name}: {process.stderr}") logger.error(f"Ошибка добавления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Исключение при добавлении пользователя {name}: {str(e)}") logger.error(f"Исключение при добавлении клиента {name}: {e}")
return False 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: 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: if process.returncode == 0:
return True return True
logger.error(f"Ошибка удаления пользователя {name}: {process.stderr}") logger.error(f"Ошибка удаления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Исключение при удалении пользователя {name}: {str(e)}") logger.error(f"Исключение при удалении клиента {name}: {e}")
return False return False
def get_client_list(): def get_client_list():
"""Возвращает список клиентов (имя и конфигурация).""" """Возвращает список клиентов в виде пар (имя, конфигурация)."""
clients = [] clients = []
users_dir = 'users' if not config.USERS_DIR.exists():
if os.path.exists(users_dir): return clients
for user_dir in os.listdir(users_dir): for user_dir in sorted(config.USERS_DIR.iterdir()):
user_path = os.path.join(users_dir, user_dir) if not user_dir.is_dir():
if os.path.isdir(user_path): continue
conf_file = os.path.join(user_path, f"{user_dir}.conf") conf_file = user_dir / f'{user_dir.name}.conf'
if os.path.exists(conf_file): if conf_file.exists():
with open(conf_file, 'r') as f: clients.append((user_dir.name, conf_file.read_text(encoding='utf-8')))
config = f.read()
clients.append((user_dir, config))
return clients return clients
def get_active_list():
"""Возвращает список активных клиентов с последним handshake.""" def get_active_list(wg_config_file, docker_container):
active = [] """Возвращает {имя клиента: время последнего handshake (UTC) или None}.
users_dir = 'users'
if os.path.exists(users_dir): Данные берутся из `wg show latest-handshakes` внутри контейнера Amnezia.
for user_dir in os.listdir(users_dir): """
user_path = os.path.join(users_dir, user_dir) interface = wg_config_file.rsplit('/', 1)[-1].rsplit('.', 1)[0]
if os.path.isdir(user_path): handshakes = {}
status_file = os.path.join(user_path, 'status.json') try:
if os.path.exists(status_file): result = subprocess.run(
with open(status_file, 'r') as f: ['docker', 'exec', '-i', docker_container, 'wg', 'show', interface, 'latest-handshakes'],
status = json.load(f) capture_output=True, text=True, timeout=30,
last_handshake = status.get('last_handshake', 'never') )
active.append((user_dir, last_handshake)) 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 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] = { data[username] = {
'expiration': expiration.isoformat() if expiration else None, '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): def get_user_expiration(username):
"""Получает срок действия подписки пользователя.""" """Возвращает срок действия подписки пользователя (aware datetime в UTC)."""
data = load_json(USER_EXPIRATION_FILE, {}) data = load_json(config.USER_EXPIRATION_FILE, {})
user_data = data.get(username, {}) expiration = data.get(username, {}).get('expiration')
expiration = user_data.get('expiration') if not expiration:
return datetime.fromisoformat(expiration) if expiration else None 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): def remove_user_expiration(username):
"""Удаляет информацию о сроке действия подписки пользователя.""" """Удаляет информацию о сроке действия подписки пользователя."""
data = load_json(USER_EXPIRATION_FILE, {}) data = load_json(config.USER_EXPIRATION_FILE, {})
if username in data: if username in data:
del data[username] del data[username]
save_json(USER_EXPIRATION_FILE, data) save_json(config.USER_EXPIRATION_FILE, data)
def set_user_telegram_id(username, telegram_id): def set_user_telegram_id(username, telegram_id):
"""Связывает имя пользователя с Telegram ID.""" """Связывает имя клиента с Telegram ID."""
data = load_json(USER_TELEGRAM_FILE, {}) data = load_json(config.USER_TELEGRAM_FILE, {})
data[username] = telegram_id data[username] = telegram_id
save_json(USER_TELEGRAM_FILE, data) save_json(config.USER_TELEGRAM_FILE, data)
def get_user_telegram_id(username): def get_user_telegram_id(username):
"""Получает Telegram ID пользователя по имени.""" """Возвращает Telegram ID владельца клиента."""
data = load_json(USER_TELEGRAM_FILE, {}) return load_json(config.USER_TELEGRAM_FILE, {}).get(username)
return data.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): 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: if code in promocodes:
return False return False
promocodes[code] = { 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, 'expires_at': expires_at.isoformat() if expires_at else None,
'max_uses': max_uses, 'max_uses': max_uses,
'uses': 0, 'uses': 0,
'subscription_period': subscription_period 'subscription_period': subscription_period,
} }
save_json(PROMOCODES_FILE, promocodes) save_json(config.PROMOCODES_FILE, promocodes)
return True return True
def apply_promocode(code):
"""Применяет промокод, увеличивает счетчик использований.""" def validate_promocode(code):
promocodes = load_json(PROMOCODES_FILE, {}) """Проверяет промокод, НЕ расходуя использование.
now = datetime.now(pytz.utc)
promo = promocodes.get(code) Возвращает {'discount': ..., 'subscription_period': ...} или None.
Счётчик использований увеличивает только consume_promocode().
"""
promo = load_json(config.PROMOCODES_FILE, {}).get(code)
if not promo: if not promo:
return None 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 return None
if promo['max_uses'] is not None and promo['uses'] >= promo['max_uses']: if promo['max_uses'] is not None and promo['uses'] >= promo['max_uses']:
return None return None
promo['uses'] += 1
save_json(PROMOCODES_FILE, promocodes)
return { return {
'discount': promo['discount'], '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(): def get_promocodes():
"""Возвращает список всех промокодов.""" """Возвращает все промокоды с разобранными датами."""
promocodes = load_json(PROMOCODES_FILE, {})
result = {} result = {}
for code, info in promocodes.items(): for code, info in load_json(config.PROMOCODES_FILE, {}).items():
result[code] = { result[code] = {
'discount': info['discount'], 'discount': info['discount'],
'expires_at': datetime.fromisoformat(info['expires_at']) if info['expires_at'] else None, 'expires_at': datetime.fromisoformat(info['expires_at']) if info['expires_at'] else None,
'max_uses': info['max_uses'], 'max_uses': info['max_uses'],
'uses': info['uses'], 'uses': info['uses'],
'subscription_period': info['subscription_period'] 'subscription_period': info['subscription_period'],
} }
return result return result
def remove_promocode(code): def remove_promocode(code):
"""Удаляет промокод.""" """Удаляет промокод."""
promocodes = load_json(PROMOCODES_FILE, {}) promocodes = load_json(config.PROMOCODES_FILE, {})
if code in promocodes: if code in promocodes:
del promocodes[code] del promocodes[code]
save_json(PROMOCODES_FILE, promocodes) save_json(config.PROMOCODES_FILE, promocodes)
return True return True
return False 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, {})
+26
Просмотреть файл
@@ -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
}
}
+177
Просмотреть файл
@@ -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]])
Regular → Executable
+2 -1
Просмотреть файл
@@ -32,7 +32,8 @@ if [[ ! "$CLIENT_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
exit 1 exit 1
fi fi
pwd=$(pwd) # Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/users/$CLIENT_NAME" mkdir -p "$pwd/users/$CLIENT_NAME"
mkdir -p "$pwd/files" mkdir -p "$pwd/files"
+94
Просмотреть файл
@@ -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
Regular → Executable
+4 -3
Просмотреть файл
@@ -12,7 +12,8 @@ CLIENT_PUBLIC_KEY="$2"
WG_CONFIG_FILE="$3" WG_CONFIG_FILE="$3"
DOCKER_CONTAINER="$4" DOCKER_CONTAINER="$4"
pwd=$(pwd) # Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/files" mkdir -p "$pwd/files"
SERVER_CONF_PATH="$pwd/files/server.conf" 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'" 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" rm -f "$pwd/users/$CLIENT_NAME/$CLIENT_NAME.conf"
rmdir "users/$CLIENT_NAME" 2>/dev/null || true rmdir "$pwd/users/$CLIENT_NAME" 2>/dev/null || true
CLIENTS_TABLE_PATH="$pwd/files/clientsTable" CLIENTS_TABLE_PATH="$pwd/files/clientsTable"
docker exec -i "$DOCKER_CONTAINER" cat /opt/amnezia/awg/clientsTable > "$CLIENTS_TABLE_PATH" || echo "[]" > "$CLIENTS_TABLE_PATH" docker exec -i "$DOCKER_CONTAINER" cat /opt/amnezia/awg/clientsTable > "$CLIENTS_TABLE_PATH" || echo "[]" > "$CLIENTS_TABLE_PATH"
+103
Просмотреть файл
@@ -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
+102
Просмотреть файл
@@ -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
-49
Просмотреть файл
@@ -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"])
-44
Просмотреть файл
@@ -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()
+56 -30
Просмотреть файл
@@ -1,10 +1,31 @@
#!/bin/bash #!/bin/bash
# Конфигурация # Конфигурация. Любое значение можно переопределить переменной окружения.
SERVICE_NAME="awg_bot" SERVICE_NAME="${AWG_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" 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/<owner>/<repo>.git -> https://api.github.com/repos/<owner>/<repo>
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' GREEN=$'\033[0;32m'
@@ -53,10 +74,10 @@ run_with_spinner() {
# Функция проверки обновлений на GitHub # Функция проверки обновлений на GitHub
check_github_updates() { check_github_updates() {
local current_sha local_sha latest_sha auto_mode="$1" 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}Текущая директория: $(pwd)${NC}"
echo -e "${YELLOW}Содержимое /root/amnezia-bot:${NC}" echo -e "${YELLOW}Содержимое $BASE_DIR:${NC}"
ls -la ls -la
# Получение текущего SHA коммита # Получение текущего SHA коммита
@@ -64,7 +85,7 @@ check_github_updates() {
# Получение последнего коммита через GitHub API # Получение последнего коммита через GitHub API
if command -v curl &>/dev/null; then 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; } [[ -z "$latest_sha" ]] && { echo -e "${RED}Не удалось получить данные с GitHub${NC}"; cd ..; return 1; }
else else
echo -e "${RED}curl не установлен${NC}"; cd ..; return 1 echo -e "${RED}curl не установлен${NC}"; cd ..; return 1
@@ -83,14 +104,14 @@ check_github_updates() {
echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}" echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}"
run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd" run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd"
fi fi
run_with_spinner "Обновление репозитория" "git pull" run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH"
echo "$latest_sha" > "$LOCAL_VERSION_FILE" echo "$latest_sha" > "$LOCAL_VERSION_FILE"
check_script_update check_script_update
# Проверка и создание виртуального окружения, если отсутствует # Проверка и создание виртуального окружения, если отсутствует
if [[ ! -d "myenv" ]]; then if [[ ! -d "myenv" ]]; then
run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv" run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv"
fi 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" run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME"
else else
echo -ne "${BLUE}1) Установить 2) Отменить: ${NC}"; read choice echo -ne "${BLUE}1) Установить 2) Отменить: ${NC}"; read choice
@@ -99,14 +120,14 @@ check_github_updates() {
echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}" echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}"
run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd" run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd"
fi fi
run_with_spinner "Обновление репозитория" "git pull" run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH"
echo "$latest_sha" > "$LOCAL_VERSION_FILE" echo "$latest_sha" > "$LOCAL_VERSION_FILE"
check_script_update check_script_update
# Проверка и создание виртуального окружения, если отсутствует # Проверка и создание виртуального окружения, если отсутствует
if [[ ! -d "myenv" ]]; then if [[ ! -d "myenv" ]]; then
run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv" run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv"
fi 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" run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME"
else else
echo -e "${YELLOW}Обновление отменено${NC}" echo -e "${YELLOW}Обновление отменено${NC}"
@@ -118,8 +139,8 @@ check_github_updates() {
# Проверка обновления самого скрипта # Проверка обновления самого скрипта
check_script_update() { check_script_update() {
local temp_script=$(mktemp) local temp_script=$(mktemp)
if [[ -f "/root/amnezia-bot/install.sh" ]]; then if [[ -f "$BASE_DIR/install.sh" ]]; then
cp "/root/amnezia-bot/install.sh" "$temp_script" cp "$BASE_DIR/install.sh" "$temp_script"
if ! cmp -s "$SCRIPT_PATH" "$temp_script"; then if ! cmp -s "$SCRIPT_PATH" "$temp_script"; then
echo -e "${YELLOW}Обнаружено обновление скрипта install.sh${NC}" echo -e "${YELLOW}Обнаружено обновление скрипта install.sh${NC}"
run_with_spinner "Обновление скрипта" "mv $temp_script $SCRIPT_PATH && chmod +x $SCRIPT_PATH" run_with_spinner "Обновление скрипта" "mv $temp_script $SCRIPT_PATH && chmod +x $SCRIPT_PATH"
@@ -130,8 +151,8 @@ check_script_update() {
fi fi
else else
echo -e "${YELLOW}Скрипт install.sh не найден в репозитории, копируем локальную версию${NC}" echo -e "${YELLOW}Скрипт install.sh не найден в репозитории, копируем локальную версию${NC}"
cp "$SCRIPT_PATH" "/root/amnezia-bot/install.sh" cp "$SCRIPT_PATH" "$BASE_DIR/install.sh"
chmod +x "/root/amnezia-bot/install.sh" chmod +x "$BASE_DIR/install.sh"
fi fi
} }
@@ -147,7 +168,11 @@ check_updates() {
fi 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 if ! command -v "$cmd" &>/dev/null; then
echo -e "${RED}$cmd не установлен. Установите $cmd для продолжения.${NC}" echo -e "${RED}$cmd не установлен. Установите $cmd для продолжения.${NC}"
exit 1 exit 1
@@ -161,22 +186,23 @@ check_updates() {
fi fi
# Проверка и клонирование репозитория, если он отсутствует # Проверка и клонирование репозитория, если он отсутствует
if [[ ! -d "/root/amnezia-bot/.git" ]]; then if [[ ! -d "$BASE_DIR/.git" ]]; then
echo -e "${YELLOW}Репозиторий не найден. Проверяем /root/amnezia-bot...${NC}" echo -e "${YELLOW}Репозиторий не найден. Проверяем $BASE_DIR...${NC}"
echo -e "${YELLOW}Содержимое /root:${NC}" echo -e "${YELLOW}Содержимое $PARENT_DIR:${NC}"
ls -la /root ls -la "$PARENT_DIR"
if [[ -d "/root/amnezia-bot" ]]; then if [[ -d "$BASE_DIR" ]]; then
echo -e "${YELLOW}Директория /root/amnezia-bot существует, но не является git-репозиторием. Удаляем её...${NC}" echo -e "${YELLOW}Директория $BASE_DIR существует, но не является git-репозиторием. Удаляем её...${NC}"
rm -rf /root/amnezia-bot || error_exit "Не удалось удалить поврежденную директорию /root/amnezia-bot" rm -rf $BASE_DIR || error_exit "Не удалось удалить поврежденную директорию $BASE_DIR"
fi fi
echo -e "${YELLOW}Клонируем репозиторий...${NC}" echo -e "${YELLOW}Клонируем репозиторий...${NC}"
cd /root || error_exit "Не удалось перейти в /root" mkdir -p "$PARENT_DIR" || error_exit "Не удалось создать $PARENT_DIR"
run_with_spinner "Клонирование репозитория" "git clone $REPO_URL" cd "$PARENT_DIR" || error_exit "Не удалось перейти в $PARENT_DIR"
cd amnezia-bot || error_exit "Не удалось перейти в каталог amnezia-bot" run_with_spinner "Клонирование репозитория" "git clone -b $REPO_BRANCH $REPO_URL $BOT_DIRNAME"
cd "$BOT_DIRNAME" || error_exit "Не удалось перейти в каталог $BOT_DIRNAME"
if [[ ! -d ".git" ]]; then if [[ ! -d ".git" ]]; then
error_exit "Клонирование репозитория не удалось. Проверьте доступ к $REPO_URL" error_exit "Клонирование репозитория не удалось. Проверьте доступ к $REPO_URL"
fi fi
echo -e "${YELLOW}Содержимое /root/amnezia-bot после клонирования:${NC}" echo -e "${YELLOW}Содержимое $BASE_DIR после клонирования:${NC}"
ls -la ls -la
fi fi
check_github_updates "$1" check_github_updates "$1"
+4 -22
Просмотреть файл
@@ -1,22 +1,4 @@
aiofiles==24.1.0 # Прямые зависимости бота. Транзитивные (aiohttp и др.) подтягивает pip.
aiogram==2.25.2 aiogram==3.31.0
aiohttp==3.8.6 APScheduler==3.11.3
aiosignal==1.3.1 pytz==2026.3.post1
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