Библиотеки: - миграция с 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).
178 строки
7.1 KiB
Python
178 строки
7.1 KiB
Python
"""Инлайн-клавиатуры бота.
|
|
|
|
В 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]])
|