Логи. Уведомления. Тексты

This commit is contained in:
Vladless
2024-10-25 20:45:12 +03:00
parent 505ab67828
commit 25c106958a
11 changed files with 189 additions and 42 deletions
+32 -6
View File
@@ -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 = ваша ссылка на поддержку
полная версия конфигурации доступна через поддержку нашего бота
```
### 🚀 Запуск
+2 -2
View File
@@ -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"
-2
View File
@@ -1,7 +1,5 @@
import json
import aiohttp
from config import SERVERS
+25 -1
View File
@@ -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()
+4 -10
View File
@@ -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
View File
@@ -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}"
+63 -4
View File
@@ -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}")
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import re
def sanitize_key_name(key_name: str) -> str:
return re.sub(r'[^a-z0-9@._-]', '', key_name.lower())