Обновление зависимостей, устранение хардкода, оплата через 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).
Этот коммит содержится в:
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
myenv/
|
||||
venv/
|
||||
.version
|
||||
|
||||
# Рабочие данные и секреты бота
|
||||
awg/files/*
|
||||
!awg/files/config.json.example
|
||||
awg/users/
|
||||
backup_*.zip
|
||||
+71
-16
@@ -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) в этом репозитории или обратитесь к разработчику.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+921
-1002
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -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 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, {})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
fi
|
||||
|
||||
pwd=$(pwd)
|
||||
# Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
|
||||
pwd="$(cd "$(dirname "$0")" && pwd)"
|
||||
mkdir -p "$pwd/users/$CLIENT_NAME"
|
||||
mkdir -p "$pwd/files"
|
||||
|
||||
|
||||
@@ -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"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
# Конфигурация
|
||||
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/<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'
|
||||
@@ -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"
|
||||
|
||||
+4
-22
@@ -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
|
||||
|
||||
Ссылка в новой задаче
Block a user