Обновление зависимостей, устранение хардкода, оплата через 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,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
|
||||
Ссылка в новой задаче
Block a user