From 8d4bec79b3d5b80a0004d587760488d05cea5502 Mon Sep 17 00:00:00 2001 From: Vladless Date: Fri, 25 Oct 2024 20:45:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8.=20=D0=A3=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F.=20=D0=A2?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 38 +++++++++++++++++---- backup.py | 4 +-- client.py | 2 -- database.py | 26 ++++++++++++++- handlers/key_management.py | 14 +++----- handlers/keys.py | 13 +++----- handlers/notifications.py | 67 +++++++++++++++++++++++++++++++++++--- handlers/pay.py | 3 ++ handlers/profile.py | 11 ++----- handlers/texts.py | 50 +++++++++++++++++++++++++++- handlers/utils.py | 3 ++ 11 files changed, 189 insertions(+), 42 deletions(-) create mode 100644 handlers/utils.py diff --git a/README.md b/README.md index 128e4d2d..687fcc8e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## 📋 Оглавление 1. [Описание](#Описание) -2. [Требования](#Требования) +2. [Стек технологий](#Стек) 3. [Установка](#установка) 4. [Конфигурация](#конфигурация) 5. [Запуск](#запуск) @@ -16,20 +16,44 @@ ## 📖 Описание -Бот предназначен для автоматизирования выдачи ключей, хранении их в базе данных и управления подключениями. -Также реализованы функции смены локации, автоматических и ручных уведомлений +Бот реализует такие функции: +- выдача ключей на пробный период +- выдача ключей на период 1мес/3мес/полгода/год +- продление ключей на эти периоды +- клиент может полностью руководить своим ключом: + * удаление + * продление + * просмотр информации (сервер, сколько осталось, сам ключ) +- смена локации (ключ перемещается с одного сервера на другой) +- клиент может иметь несколько ключей (несколько устройств) +- реферальная программа по пригласительной ссылке +- инструкции +- пополнение баланса через сервис Юкасса +- периодические бэкапы базы данных клиентов +- функции уведомлений: + * произвольные: можно через админку отправить любое информационное сообщение + * о не использованном пробнике + * о истекающих ключах (за сутки/6 часов/момент истечения) +- чат поддержки/канал/ +- автоматическое продление ключа, если у клиента есть баланс на нужную сумму --- -## 💻 Требования +## 💻 Стек -Для работы проекта необходимы следующие компоненты: +Проект использует следущие компоненты - **Python** версии 3.8 или выше - **Git** для клонирования репозитория - **Virtualenv** для создания виртуального окружения (рекомендуется) -- **postgresql** для работы базы данных +- **postgresql** база данных +- **nginx** для работы с нужными вебхуками +- **aiogram** используется новейшая версия 3.13 +- **youkassa** для работы с кассой +- **aiohttp** для api запросов к панелям + +Проект полностью асинхронный, что обеспечивает высокую скорость работы --- @@ -85,6 +109,8 @@ WEBHOOK_HOST = адрес вашего сервера для вебхуков WEBHOOK_PATH = '/webhook/' WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}" SUPPORT_CHAT_URL = ваша ссылка на поддержку + +полная версия конфигурации доступна через поддержку нашего бота ``` ### 🚀 Запуск diff --git a/backup.py b/backup.py index 5c9ca24c..5397c631 100644 --- a/backup.py +++ b/backup.py @@ -6,14 +6,14 @@ from datetime import datetime from aiogram.types import BufferedInputFile from bot import bot -from config import ADMIN_ID, DB_NAME, DB_PASSWORD, DB_USER +from config import ADMIN_ID, DB_NAME, DB_PASSWORD, DB_USER, BACK_DIR async def backup_database(): USER = DB_USER HOST = "localhost" - BACKUP_DIR = "/home/vlad" + BACKUP_DIR = BACK_DIR DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S") BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql" diff --git a/client.py b/client.py index b2b87c75..ec2abc70 100644 --- a/client.py +++ b/client.py @@ -1,7 +1,5 @@ import json -import aiohttp - from config import SERVERS diff --git a/database.py b/database.py index 4e968733..6321f57a 100644 --- a/database.py +++ b/database.py @@ -192,4 +192,28 @@ async def get_referral_stats(referrer_tg_id: int): return { 'total_referrals': total_referrals, 'active_referrals': active_referrals - } \ No newline at end of file + } + +async def update_key_expiry(client_id: str, new_expiry_time: int): + """ + Обновление времени истечения ключа на новое значение. + """ + conn = await asyncpg.connect(DATABASE_URL) + await conn.execute(''' + UPDATE keys + SET expiry_time = $1, notified = FALSE, notified_24h = FALSE + WHERE client_id = $2 + ''', new_expiry_time, client_id) + await conn.close() + + +async def delete_key(client_id: str): + """ + Удаление ключа из базы данных. + """ + conn = await asyncpg.connect(DATABASE_URL) + await conn.execute(''' + DELETE FROM keys + WHERE client_id = $1 + ''', client_id) + await conn.close() \ No newline at end of file diff --git a/handlers/key_management.py b/handlers/key_management.py index f6dc8197..43276d3e 100644 --- a/handlers/key_management.py +++ b/handlers/key_management.py @@ -21,13 +21,11 @@ from handlers.instructions import send_instructions from handlers.pay import ReplenishBalanceState, process_custom_amount_input from handlers.profile import process_callback_view_profile from handlers.start import start_command -from handlers.texts import KEY, KEY_TRIAL, NULL_BALANCE +from handlers.texts import KEY, KEY_TRIAL, NULL_BALANCE, TRIAL, key_message_success +from handlers.utils import sanitize_key_name router = Router() -def sanitize_key_name(key_name: str) -> str: - return re.sub(r'[^a-z0-9@._-]', '', key_name.lower()) - class Form(StatesGroup): waiting_for_server_selection = State() waiting_for_key_name = State() @@ -144,8 +142,7 @@ async def handle_send_trial_command(message: types.Message, state: FSMContext): for record in records: tg_id = record['tg_id'] trial_message = ( - "🎉 Вам предоставлен пробный период на 1 день!\n" - "Пожалуйста, воспользуйтесь им, чтобы протестировать наш сервис." + TRIAL ) try: await bot.send_message(chat_id=tg_id, text=trial_message) @@ -315,10 +312,7 @@ async def handle_key_name_input(message: Message, state: FSMContext): ]) key_message = ( - "✅ Ключ успешно создан:\n" - f"
{connection_link}
\n\n" - f"{remaining_time_message}\n\n" - "Добавьте ключ в приложение по инструкции ниже:" + key_message_success(connection_link, remaining_time_message) ) await message.bot.send_message(tg_id, key_message, parse_mode="HTML", reply_markup=keyboard) diff --git a/handlers/keys.py b/handlers/keys.py index cce4ca63..275175e6 100644 --- a/handlers/keys.py +++ b/handlers/keys.py @@ -10,6 +10,7 @@ from client import add_client, delete_client, extend_client_key from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, SERVERS from database import get_balance, update_balance from handlers.texts import NO_KEYS +from handlers.texts import key_message, key_relocated locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8') @@ -99,11 +100,9 @@ async def process_callback_view_key(callback_query: types.CallbackQuery): formatted_expiry_date = expiry_date.strftime('%d %B %Y года') - response_message = (f"🔑 Ваш ключ:\n
{key}
\n" - f"📅 Дата окончания: {formatted_expiry_date}\n" - f"{days_left_message}\n" - f"🌍 Сервер: {server_name}\n\n" # Отступ между сервером и текстом - f"Скопируйте ключ и перейдите в инструкции👇") # Курсивный текст + response_message = ( + key_message(key, formatted_expiry_date, days_left_message, server_name) + ) renew_button = types.InlineKeyboardButton(text='⏳ Продлить ключ', callback_data=f'renew_key|{client_id}') instructions_button = types.InlineKeyboardButton(text='📘 Инструкции', callback_data='instructions') @@ -354,9 +353,7 @@ async def process_callback_select_server(callback_query: types.CallbackQuery): raise Exception(f"Ошибка при удалении клиента с сервера {current_server_id}") response_message = ( - f"Ключ успешно перемещен на новый сервер.\n\n" - f"Удалите старый ключ из вашего приложения и используйте новый для подключения к новому серверу:\n" - f"
{new_key}
" + key_relocated(new_key) ) except Exception as e: response_message = f"Ключ перемещен, но возникла ошибка при удалении клиента с текущего сервера: {e}" diff --git a/handlers/notifications.py b/handlers/notifications.py index 1830917b..ff61b7ba 100644 --- a/handlers/notifications.py +++ b/handlers/notifications.py @@ -1,11 +1,15 @@ from datetime import datetime, timedelta - import asyncpg from aiogram import Bot, Router from aiogram.fsm.state import State, StatesGroup +import logging +from config import DATABASE_URL, ADMIN_USERNAME, ADMIN_PASSWORD +from database import get_balance, update_key_expiry, delete_key +from client import extend_client_key, delete_client +from auth import login_with_credentials -from config import DATABASE_URL -from database import get_balance +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) router = Router() @@ -15,16 +19,21 @@ class NotificationStates(StatesGroup): async def notify_expiring_keys(bot: Bot): try: conn = await asyncpg.connect(DATABASE_URL) + logger.info("Подключение к базе данных успешно.") try: current_time = datetime.utcnow().timestamp() * 1000 threshold_time_10h = (datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000 threshold_time_24h = (datetime.utcnow() + timedelta(days=1)).timestamp() * 1000 + logger.info("Начало обработки уведомлений.") + + # Уведомления за 10 часов до истечения records = await conn.fetch(''' SELECT tg_id, email, expiry_time, client_id, server_id FROM keys WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE ''', threshold_time_10h, current_time) + logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.") for record in records: tg_id = record['tg_id'] email = record['email'] @@ -36,14 +45,18 @@ async def notify_expiring_keys(bot: Bot): f"Дата истечения: {datetime.utcfromtimestamp(expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')}" await bot.send_message(tg_id, message) + logger.info(f"Уведомление отправлено пользователю {tg_id}.") await conn.execute('UPDATE keys SET notified = TRUE WHERE client_id = $1', record['client_id']) + logger.info(f"Обновлено поле notified для клиента {record['client_id']}.") + # Уведомления за 24 часа до истечения records_24h = await conn.fetch(''' SELECT tg_id, email, expiry_time, client_id, server_id FROM keys WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE ''', threshold_time_24h, current_time) + logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.") for record in records_24h: tg_id = record['tg_id'] email = record['email'] @@ -63,10 +76,56 @@ async def notify_expiring_keys(bot: Bot): f"Баланс: {balance:.2f} руб." await bot.send_message(tg_id, message_24h) + logger.info(f"Уведомление за 24 часа отправлено пользователю {tg_id}.") await conn.execute('UPDATE keys SET notified_24h = TRUE WHERE client_id = $1', record['client_id']) + logger.info(f"Обновлено поле notified_24h для клиента {record['client_id']}.") + + # Обработка ключей по истечению + expiring_keys = await conn.fetch(''' + SELECT tg_id, client_id, expiry_time, server_id FROM keys + WHERE expiry_time <= $1 + ''', current_time) + + logger.info(f"Найдено {len(expiring_keys)} истекающих ключей.") + for record in expiring_keys: + tg_id = record['tg_id'] + client_id = record['client_id'] + balance = await get_balance(tg_id) + server_id = record['server_id'] + + logger.info(f"Проверка баланса для клиента {client_id}: {balance}.") + + if balance >= 100: + new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000) + await update_key_expiry(client_id, new_expiry_time) + logger.info(f"Ключ для клиента {client_id} продлен до {datetime.utcfromtimestamp(new_expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')}.") + + session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD) + success = await extend_client_key(session, server_id, tg_id, client_id, email, new_expiry_time) + if success: + await bot.send_message(tg_id, "Ваш ключ был продлен на месяц.") + logger.info(f"Ключ для пользователя {tg_id} успешно продлен на месяц.") + else: + await bot.send_message(tg_id, "Не удалось продлить ключ на панели, обратитесь в поддержку.") + logger.error(f"Не удалось продлить ключ для пользователя {tg_id}.") + + else: + await delete_key(client_id) + logger.info(f"Ключ для клиента {client_id} удален из-за недостаточного баланса.") + + session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD) + success = await delete_client(session, server_id, client_id) + if success: + await bot.send_message(tg_id, "Ваш ключ был удален из-за недостаточного баланса.") + logger.info(f"Ключ для пользователя {tg_id} удален.") + else: + await bot.send_message(tg_id, "Не удалось удалить ключ с панели, обратитесь в поддержку.") + logger.error(f"Не удалось удалить ключ для пользователя {tg_id}.") finally: await conn.close() + logger.info("Соединение с базой данных закрыто.") except Exception as e: - print(f"Ошибка при отправке уведомлений: {e}") + logger.error(f"Ошибка при отправке уведомлений: {e}") + diff --git a/handlers/pay.py b/handlers/pay.py index bd14da17..2dfefe25 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -126,6 +126,9 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F "currency": "RUB" }, "vat_code": 6 + ## Раскоментируйте следующие строки, если у вас регистрация как ИП, а не самозанятость + ## "payment_subject": "payment", + ## "payment_mode": "full_payment", } ] }, diff --git a/handlers/profile.py b/handlers/profile.py index 24f46759..cdf736c3 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from bot import bot from database import get_balance, get_key_count, get_referral_stats +from handlers.texts import profile_message_send, invite_message_send class ReplenishBalanceState(StatesGroup): @@ -23,10 +24,7 @@ async def process_callback_view_profile(callback_query: types.CallbackQuery, sta balance = 0 profile_message = ( - f"Профиль: {username}\n\n" - f"🔹 ID: {tg_id}\n" - f"🔹 Баланс: {balance} RUB\n" - f"🔹 К-во устройств: {key_count}\n\n" + profile_message_send(username, tg_id, balance, key_count) ) profile_message += ( @@ -73,10 +71,7 @@ async def invite_handler(callback_query: types.CallbackQuery): referral_stats = await get_referral_stats(tg_id) invite_message = ( - f"👥 Ваша реферальная ссылка:\n
{referral_link}
\n" - f"Пригласите реферала и получайте 25% с его каждого пополнения!\n\n" - f"🔹 Всего приглашено: {referral_stats['total_referrals']}\n" - f"🔹 Активных рефералов: {referral_stats['active_referrals']}" + invite_message_send(referral_link,referral_stats) ) button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='view_profile') diff --git a/handlers/texts.py b/handlers/texts.py index 2824dd17..c57f4367 100644 --- a/handlers/texts.py +++ b/handlers/texts.py @@ -40,6 +40,11 @@ KEY_TRIAL = ( "Пожалуйста, введите название для вашего пробного ключа:" ) +TRIAL = ( + "🎉 Вам предоставлен пробный период на 1 день!\n" + "Пожалуйста, воспользуйтесь им, чтобы протестировать наш сервис." + ) + NULL_BALANCE = ( "❗️ Недостаточно средств на балансе для создания нового ключа. " "Пожалуйста, пополните баланс." @@ -61,4 +66,47 @@ INSTRUCTIONS = ( "3. **Откройте приложение и нажмите на плюсик сверху справа.**\n" "4. **Выберите 'Вставить из буфера обмена' для добавления ключа.**\n\n" "💬 Если у вас возникнут вопросы, не стесняйтесь обращаться в [поддержку](https://t.me/solonet_sup)." -) \ No newline at end of file +) + +def key_message_success(connection_link, remaining_time_message): + key_message = ( + "✅ Ключ успешно создан:\n" + f"
{connection_link}
\n\n" + f"{remaining_time_message}\n\n" + "Добавьте ключ в приложение по инструкции ниже:" + ) + return key_message + +def key_message(key, formatted_expiry_date, days_left_message, server_name): + response_message = (f"🔑 Ваш ключ:\n
{key}
\n" + f"📅 Дата окончания: {formatted_expiry_date}\n" + f"{days_left_message}\n" + f"🌍 Сервер: {server_name}\n\n" + f"Скопируйте ключ и перейдите в инструкции👇") + return response_message + +def key_relocated(new_key): + response_message = ( + f"Ключ успешно перемещен на новый сервер.\n\n" + f"Удалите старый ключ из вашего приложения и используйте новый для подключения к новому серверу:\n" + f"
{new_key}
" + ) + return response_message + +def profile_message_send(username, tg_id, balance, key_count): + profile_message = ( + f"Профиль: {username}\n\n" + f"🔹 ID: {tg_id}\n" + f"🔹 Баланс: {balance} RUB\n" + f"🔹 К-во устройств: {key_count}\n\n" + ) + return profile_message + +def invite_message_send(referral_link,referral_stats): + invite_message = ( + f"👥 Ваша реферальная ссылка:\n
{referral_link}
\n" + f"Пригласите реферала и получайте 25% с его каждого пополнения!\n\n" + f"🔹 Всего приглашено: {referral_stats['total_referrals']}\n" + f"🔹 Активных рефералов: {referral_stats['active_referrals']}" + ) + return invite_message \ No newline at end of file diff --git a/handlers/utils.py b/handlers/utils.py new file mode 100644 index 00000000..5c2436f8 --- /dev/null +++ b/handlers/utils.py @@ -0,0 +1,3 @@ +import re +def sanitize_key_name(key_name: str) -> str: + return re.sub(r'[^a-z0-9@._-]', '', key_name.lower()) \ No newline at end of file