@@ -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 = ваша ссылка на поддержку
|
||||
|
||||
полная версия конфигурации доступна через поддержку нашего бота
|
||||
```
|
||||
|
||||
### 🚀 Запуск
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+25
-1
@@ -192,4 +192,28 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
return {
|
||||
'total_referrals': total_referrals,
|
||||
'active_referrals': active_referrals
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -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"<pre>{connection_link}</pre>\n\n"
|
||||
f"{remaining_time_message}\n\n"
|
||||
"<i>Добавьте ключ в приложение по инструкции ниже:</i>"
|
||||
key_message_success(connection_link, remaining_time_message)
|
||||
)
|
||||
|
||||
await message.bot.send_message(tg_id, key_message, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
+5
-8
@@ -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"🔑 <b>Ваш ключ:</b>\n<pre>{key}</pre>\n"
|
||||
f"📅 <b>Дата окончания:</b> {formatted_expiry_date}\n"
|
||||
f"{days_left_message}\n"
|
||||
f"🌍 <b>Сервер:</b> {server_name}\n\n" # Отступ между сервером и текстом
|
||||
f"<i>Скопируйте ключ и перейдите в инструкции👇</i>") # Курсивный текст
|
||||
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"<b>Удалите старый ключ из вашего приложения и используйте новый для подключения к новому серверу:</b>\n"
|
||||
f"<pre>{new_key}</pre>"
|
||||
key_relocated(new_key)
|
||||
)
|
||||
except Exception as e:
|
||||
response_message = f"Ключ перемещен, но возникла ошибка при удалении клиента с текущего сервера: {e}"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+3
-8
@@ -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"<b>Профиль: {username}</b>\n\n"
|
||||
f"🔹 <b>ID:</b> {tg_id}\n"
|
||||
f"🔹 <b>Баланс:</b> {balance} RUB\n"
|
||||
f"🔹 <b>К-во устройств:</b> {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"👥 <b>Ваша реферальная ссылка:</b>\n<pre>{referral_link}</pre>\n"
|
||||
f"<i>Пригласите реферала и получайте 25% с его каждого пополнения!</i>\n\n"
|
||||
f"🔹 <b>Всего приглашено:</b> {referral_stats['total_referrals']}\n"
|
||||
f"🔹 <b>Активных рефералов:</b> {referral_stats['active_referrals']}"
|
||||
invite_message_send(referral_link,referral_stats)
|
||||
)
|
||||
|
||||
button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='view_profile')
|
||||
|
||||
+49
-1
@@ -40,6 +40,11 @@ KEY_TRIAL = (
|
||||
"<i>Пожалуйста, введите название для вашего пробного ключа:</i>"
|
||||
)
|
||||
|
||||
TRIAL = (
|
||||
"🎉 Вам предоставлен пробный период на 1 день!\n"
|
||||
"Пожалуйста, воспользуйтесь им, чтобы протестировать наш сервис."
|
||||
)
|
||||
|
||||
NULL_BALANCE = (
|
||||
"❗️ Недостаточно средств на балансе для создания нового ключа. "
|
||||
"Пожалуйста, пополните баланс."
|
||||
@@ -61,4 +66,47 @@ INSTRUCTIONS = (
|
||||
"3. **Откройте приложение и нажмите на плюсик сверху справа.**\n"
|
||||
"4. **Выберите 'Вставить из буфера обмена' для добавления ключа.**\n\n"
|
||||
"💬 Если у вас возникнут вопросы, не стесняйтесь обращаться в [поддержку](https://t.me/solonet_sup)."
|
||||
)
|
||||
)
|
||||
|
||||
def key_message_success(connection_link, remaining_time_message):
|
||||
key_message = (
|
||||
"✅ Ключ успешно создан:\n"
|
||||
f"<pre>{connection_link}</pre>\n\n"
|
||||
f"{remaining_time_message}\n\n"
|
||||
"<i>Добавьте ключ в приложение по инструкции ниже:</i>"
|
||||
)
|
||||
return key_message
|
||||
|
||||
def key_message(key, formatted_expiry_date, days_left_message, server_name):
|
||||
response_message = (f"🔑 <b>Ваш ключ:</b>\n<pre>{key}</pre>\n"
|
||||
f"📅 <b>Дата окончания:</b> {formatted_expiry_date}\n"
|
||||
f"{days_left_message}\n"
|
||||
f"🌍 <b>Сервер:</b> {server_name}\n\n"
|
||||
f"<i>Скопируйте ключ и перейдите в инструкции👇</i>")
|
||||
return response_message
|
||||
|
||||
def key_relocated(new_key):
|
||||
response_message = (
|
||||
f"Ключ успешно перемещен на новый сервер.\n\n"
|
||||
f"<b>Удалите старый ключ из вашего приложения и используйте новый для подключения к новому серверу:</b>\n"
|
||||
f"<pre>{new_key}</pre>"
|
||||
)
|
||||
return response_message
|
||||
|
||||
def profile_message_send(username, tg_id, balance, key_count):
|
||||
profile_message = (
|
||||
f"<b>Профиль: {username}</b>\n\n"
|
||||
f"🔹 <b>ID:</b> {tg_id}\n"
|
||||
f"🔹 <b>Баланс:</b> {balance} RUB\n"
|
||||
f"🔹 <b>К-во устройств:</b> {key_count}\n\n"
|
||||
)
|
||||
return profile_message
|
||||
|
||||
def invite_message_send(referral_link,referral_stats):
|
||||
invite_message = (
|
||||
f"👥 <b>Ваша реферальная ссылка:</b>\n<pre>{referral_link}</pre>\n"
|
||||
f"<i>Пригласите реферала и получайте 25% с его каждого пополнения!</i>\n\n"
|
||||
f"🔹 <b>Всего приглашено:</b> {referral_stats['total_referrals']}\n"
|
||||
f"🔹 <b>Активных рефералов:</b> {referral_stats['active_referrals']}"
|
||||
)
|
||||
return invite_message
|
||||
@@ -0,0 +1,3 @@
|
||||
import re
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
return re.sub(r'[^a-z0-9@._-]', '', key_name.lower())
|
||||
Reference in New Issue
Block a user