Библиотеки: - миграция с 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).
370 строки
14 KiB
Python
370 строки
14 KiB
Python
"""Хранилище данных бота (JSON-файлы) и операции с клиентами WireGuard."""
|
|
import json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime
|
|
|
|
import pytz
|
|
|
|
import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def load_json(file_path, default=None):
|
|
"""Загружает JSON-файл, возвращает default при ошибке или отсутствии файла."""
|
|
try:
|
|
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}: {e}")
|
|
return default if default is not None else {}
|
|
|
|
|
|
def save_json(file_path, data):
|
|
"""Сохраняет данные в JSON-файл (атомарно, через временный файл)."""
|
|
try:
|
|
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}: {e}")
|
|
return False
|
|
|
|
|
|
# --- Конфигурация -----------------------------------------------------------
|
|
|
|
def get_config():
|
|
"""Возвращает конфигурацию из config.json."""
|
|
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 администратора в конфигурацию."""
|
|
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))
|
|
cfg['admin_ids'] = admin_ids
|
|
save_config(cfg)
|
|
|
|
|
|
def remove_admin(admin_id):
|
|
"""Удаляет ID администратора из конфигурации."""
|
|
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):
|
|
"""Устанавливает цену для указанного периода подписки."""
|
|
cfg = get_config()
|
|
pricing = cfg.get('pricing', dict(config.DEFAULT_PRICING))
|
|
pricing[period] = price
|
|
cfg['pricing'] = pricing
|
|
save_config(cfg)
|
|
|
|
|
|
# --- Клиенты 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:
|
|
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.strip() or process.stdout.strip()}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Исключение при добавлении клиента {name}: {e}")
|
|
return False
|
|
|
|
|
|
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(
|
|
[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.strip() or process.stdout.strip()}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Исключение при удалении клиента {name}: {e}")
|
|
return False
|
|
|
|
|
|
def get_client_list():
|
|
"""Возвращает список клиентов в виде пар (имя, конфигурация)."""
|
|
clients = []
|
|
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(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(config.USER_EXPIRATION_FILE, {})
|
|
data[username] = {
|
|
'expiration': expiration.isoformat() if expiration else None,
|
|
'transfer_limit': transfer_limit,
|
|
}
|
|
save_json(config.USER_EXPIRATION_FILE, data)
|
|
|
|
|
|
def get_user_expiration(username):
|
|
"""Возвращает срок действия подписки пользователя (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(config.USER_EXPIRATION_FILE, {})
|
|
if username in data:
|
|
del data[username]
|
|
save_json(config.USER_EXPIRATION_FILE, data)
|
|
|
|
|
|
def set_user_telegram_id(username, telegram_id):
|
|
"""Связывает имя клиента с Telegram ID."""
|
|
data = load_json(config.USER_TELEGRAM_FILE, {})
|
|
data[username] = telegram_id
|
|
save_json(config.USER_TELEGRAM_FILE, data)
|
|
|
|
|
|
def get_user_telegram_id(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(config.PROMOCODES_FILE, {})
|
|
if code in promocodes:
|
|
return False
|
|
promocodes[code] = {
|
|
'discount': discount,
|
|
'expires_at': expires_at.isoformat() if expires_at else None,
|
|
'max_uses': max_uses,
|
|
'uses': 0,
|
|
'subscription_period': subscription_period,
|
|
}
|
|
save_json(config.PROMOCODES_FILE, promocodes)
|
|
return True
|
|
|
|
|
|
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']) < datetime.now(pytz.utc):
|
|
return None
|
|
if promo['max_uses'] is not None and promo['uses'] >= promo['max_uses']:
|
|
return None
|
|
return {
|
|
'discount': promo['discount'],
|
|
'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():
|
|
"""Возвращает все промокоды с разобранными датами."""
|
|
result = {}
|
|
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'],
|
|
}
|
|
return result
|
|
|
|
|
|
def remove_promocode(code):
|
|
"""Удаляет промокод."""
|
|
promocodes = load_json(config.PROMOCODES_FILE, {})
|
|
if code in promocodes:
|
|
del promocodes[code]
|
|
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, {})
|