Библиотеки: - миграция с 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).
104 строки
3.9 KiB
Python
104 строки
3.9 KiB
Python
"""Загрузка и валидация настроек бота.
|
|
|
|
Значения берутся из 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
|