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