diff --git a/.flake8 b/.flake8
index 5b3aa68f..68f40a2d 100644
--- a/.flake8
+++ b/.flake8
@@ -1,6 +1,6 @@
[flake8]
max-line-length = 250
-ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305
+ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305, E701
max-complexity = 25
select = B, C, E, F, W, T4, B9
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,.txt
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 2c9c052d..2b66b41b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,5 @@ handlers/texts.py
Thumbs.db
nginx.conf
-scripts/nginx.sh
+scripts
+models.py
\ No newline at end of file
diff --git a/backup.py b/backup.py
index 27839beb..9a71da8b 100644
--- a/backup.py
+++ b/backup.py
@@ -1,6 +1,7 @@
import os
import subprocess
from datetime import datetime
+from typing import Union
from aiogram.types import BufferedInputFile
@@ -11,6 +12,15 @@ from logger import logger
async def backup_database():
from bot import bot
+ try:
+ if backup_file_path := _create_database_backup():
+ await _send_backup_to_admin(bot, backup_file_path)
+ _cleanup_old_backups()
+ except Exception as e:
+ logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
+
+
+def _create_database_backup():
USER = DB_USER
HOST = "localhost"
BACKUP_DIR = BACK_DIR
@@ -25,25 +35,37 @@ async def backup_database():
check=True,
)
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
+ return BACKUP_FILE
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
- return
+ return None
+ finally:
+ del os.environ["PGPASSWORD"]
+
+async def _send_backup_to_admin(bot, backup_file_path):
try:
- with open(BACKUP_FILE, "rb") as backup_file:
+ with open(backup_file_path, "rb") as backup_file:
backup_input_file = BufferedInputFile(
- backup_file.read(), filename=os.path.basename(BACKUP_FILE)
+ backup_file.read(), filename=os.path.basename(backup_file_path)
)
- await bot.send_document(ADMIN_ID, backup_input_file)
+ admin_ids: Union[int, list[int]] = ADMIN_ID
+ if isinstance(admin_ids, list):
+ for id in admin_ids:
+ await bot.send_document(id, backup_input_file)
+ else:
+ await bot.send_document(admin_ids, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
+
+def _cleanup_old_backups():
try:
subprocess.run(
[
"find",
- BACKUP_DIR,
+ BACK_DIR,
"-type",
"f",
"-name",
@@ -60,5 +82,3 @@ async def backup_database():
logger.info("Старые бэкапы удалены.")
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
-
- del os.environ["PGPASSWORD"]
diff --git a/bot.py b/bot.py
index 63264af4..310fa61d 100644
--- a/bot.py
+++ b/bot.py
@@ -2,8 +2,9 @@ from aiogram import Bot, Dispatcher, Router
from aiogram.fsm.storage.memory import MemoryStorage
from config import API_TOKEN, CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE
-from middlewares.database import DatabaseMiddleware
+from middlewares.admin import AdminMiddleware
from middlewares.logging import LoggingMiddleware
+from middlewares.user import UserMiddleware
bot = Bot(token=API_TOKEN)
storage = MemoryStorage()
@@ -44,6 +45,8 @@ if ROBOKASSA_ENABLE:
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
+dp.message.middleware(AdminMiddleware())
+dp.callback_query.middleware(AdminMiddleware())
-dp.message.middleware(DatabaseMiddleware())
-dp.callback_query.middleware(DatabaseMiddleware())
+dp.message.middleware(UserMiddleware())
+dp.callback_query.middleware(UserMiddleware())
diff --git a/database.py b/database.py
index 186c77ea..69a2c956 100644
--- a/database.py
+++ b/database.py
@@ -9,6 +9,22 @@ from logger import logger
async def init_db():
conn = await asyncpg.connect(DATABASE_URL)
+ # Таблица для хранения основной информации о пользователях из Telegram
+ await conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ tg_id BIGINT PRIMARY KEY NOT NULL,
+ username TEXT,
+ first_name TEXT,
+ last_name TEXT,
+ language_code TEXT,
+ is_bot BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+
# Таблица для хранения информации о пользователях
await conn.execute(
"""
@@ -420,3 +436,58 @@ async def get_tg_id_by_client_id(client_id: str):
return result["tg_id"] if result else None
finally:
await conn.close()
+
+
+async def upsert_user(
+ tg_id: int,
+ username: str = None,
+ first_name: str = None,
+ last_name: str = None,
+ language_code: str = None,
+ is_bot: bool = False,
+):
+ """
+ Создает или обновляет информацию о пользователе в базе данных.
+
+ Args:
+ tg_id (int): Уникальный идентификатор пользователя в Telegram
+ username (str, optional): Никнейм пользователя
+ first_name (str, optional): Имя пользователя
+ last_name (str, optional): Фамилия пользователя
+ language_code (str, optional): Код языка пользователя
+ is_bot (bool, optional): Флаг, указывающий является ли пользователь ботом
+ """
+ conn = await asyncpg.connect(DATABASE_URL)
+ try:
+ await conn.execute(
+ """
+ INSERT INTO users (tg_id, username, first_name, last_name, language_code, is_bot, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON CONFLICT (tg_id) DO UPDATE
+ SET
+ username = COALESCE(EXCLUDED.username, users.username),
+ first_name = COALESCE(EXCLUDED.first_name, users.first_name),
+ last_name = COALESCE(EXCLUDED.last_name, users.last_name),
+ language_code = COALESCE(EXCLUDED.language_code, users.language_code),
+ is_bot = EXCLUDED.is_bot,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ tg_id,
+ username,
+ first_name,
+ last_name,
+ language_code,
+ is_bot,
+ )
+
+ # Создаем запись в connections, если ее еще нет
+ await conn.execute(
+ """
+ INSERT INTO connections (tg_id, balance, trial)
+ VALUES ($1, 0.0, 0)
+ ON CONFLICT (tg_id) DO NOTHING
+ """,
+ tg_id,
+ )
+ finally:
+ await conn.close()
diff --git a/filters/admin.py b/filters/admin.py
index 7ecd1f7d..17eddb73 100644
--- a/filters/admin.py
+++ b/filters/admin.py
@@ -1,3 +1,5 @@
+from typing import Union
+
from aiogram.filters import BaseFilter
from aiogram.types import Message
@@ -6,9 +8,13 @@ from config import ADMIN_ID
class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool:
- if isinstance(ADMIN_ID, list):
- return message.from_user.id in ADMIN_ID
- elif isinstance(ADMIN_ID, int):
- return message.from_user.id == ADMIN_ID
- else:
+ try:
+ admin_ids: Union[int, list[int]] = ADMIN_ID
+
+ if isinstance(admin_ids, list):
+ return message.from_user.id in admin_ids
+
+ return message.from_user.id == admin_ids
+
+ except Exception:
return False
diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py
index 86f581b1..6c786cd3 100644
--- a/handlers/admin/admin_coupons.py
+++ b/handlers/admin/admin_coupons.py
@@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import create_coupon, delete_coupon_from_db, get_all_coupons
+from filters.admin import IsAdminFilter
from logger import logger
@@ -15,7 +16,7 @@ class AdminCouponsState(StatesGroup):
router = Router()
-@router.callback_query(F.data == "coupons_editor")
+@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
async def show_coupon_management_menu(callback_query: types.CallbackQuery):
try:
await callback_query.message.delete()
@@ -38,7 +39,7 @@ async def show_coupon_management_menu(callback_query: types.CallbackQuery):
await callback_query.answer()
-@router.callback_query(F.data == "coupons")
+@router.callback_query(F.data == "coupons", IsAdminFilter())
async def show_coupon_list(callback_query: types.CallbackQuery):
try:
try:
@@ -99,7 +100,7 @@ async def show_coupon_list(callback_query: types.CallbackQuery):
await callback_query.answer()
-@router.callback_query(F.data.startswith("delete_coupon_"))
+@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
async def handle_delete_coupon(callback_query: types.CallbackQuery):
coupon_code = callback_query.data[len("delete_coupon_") :]
@@ -127,24 +128,29 @@ async def handle_delete_coupon(callback_query: types.CallbackQuery):
await callback_query.answer()
-@router.callback_query(F.data == "create_coupon")
+@router.callback_query(F.data == "create_coupon", IsAdminFilter())
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
try:
await callback_query.message.delete()
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
+ builder = InlineKeyboardBuilder()
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
+ markup = builder.as_markup()
+
await callback_query.message.answer(
"Введите данные для создания купона в формате:\n\n"
"код сумма лимит\n\n"
"Пример: 'COUPON1 50 5'\n\n",
parse_mode="HTML",
+ reply_markup=markup,
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
await callback_query.answer()
-@router.message(AdminCouponsState.waiting_for_coupon_data)
+@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def process_coupon_data(message: types.Message, state: FSMContext):
text = message.text.strip()
@@ -202,6 +208,7 @@ async def process_coupon_data(message: types.Message, state: FSMContext):
@router.callback_query(F.data == "back_to_coupons_menu")
-async def back_to_coupons_menu(callback_query: types.CallbackQuery):
+async def back_to_coupons_menu(callback_query: types.CallbackQuery, state: FSMContext):
"""Возвращаем пользователя в меню управления купонами"""
+ await state.clear()
await show_coupon_management_menu(callback_query)
diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py
index c840524f..414fdbd0 100644
--- a/handlers/admin/admin_panel.py
+++ b/handlers/admin/admin_panel.py
@@ -23,8 +23,13 @@ class UserEditorState(StatesGroup):
displaying_user_info = State()
-@router.message(Command("admin"), IsAdminFilter())
-async def handle_admin_command(message: types.Message):
+@router.callback_query(F.data == "admin", IsAdminFilter())
+async def handle_admin_callback_query(callback_query: CallbackQuery):
+ await handle_admin_message(callback_query.message)
+
+
+@router.message(Command("admin"), F.data == "admin", IsAdminFilter())
+async def handle_admin_message(message: types.Message):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
@@ -50,6 +55,9 @@ async def handle_admin_command(message: types.Message):
builder.row(
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
)
+ builder.row(
+ InlineKeyboardButton(text="⬅️ Вернуться в профиль", callback_data="view_profile")
+ )
await bot.send_message(
message.chat.id, "🤖 Панель администратора", reply_markup=builder.as_markup()
)
diff --git a/handlers/commands.py b/handlers/commands.py
index aa064a22..3ecebb00 100644
--- a/handlers/commands.py
+++ b/handlers/commands.py
@@ -8,10 +8,10 @@ router = Router()
@router.message(Command("start"))
-async def handle_start(message: types.Message, state: FSMContext):
- await start_command(message)
+async def handle_start(message: types.Message, state: FSMContext, admin: bool = False):
+ await start_command(message, admin)
@router.message(Command("menu"))
-async def handle_menu(message: types.Message, state: FSMContext):
- await start_command(message)
+async def handle_menu(message: types.Message, state: FSMContext, admin: bool = False):
+ await start_command(message, admin)
diff --git a/handlers/instructions/instructions.jpg b/handlers/instructions/instructions.jpg
deleted file mode 100644
index 9c52d9f6..00000000
Binary files a/handlers/instructions/instructions.jpg and /dev/null differ
diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py
index 458af5b5..3cf8acfa 100644
--- a/handlers/instructions/instructions.py
+++ b/handlers/instructions/instructions.py
@@ -16,7 +16,7 @@ async def send_instructions(callback_query: types.CallbackQuery):
instructions_message = INSTRUCTIONS
- image_path = os.path.join(os.path.dirname(__file__), "instructions.jpg")
+ image_path = os.path.join("img", "instructions.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
diff --git a/handlers/keys/key_management.py b/handlers/keys/key_management.py
index de856809..c04c63bf 100644
--- a/handlers/keys/key_management.py
+++ b/handlers/keys/key_management.py
@@ -9,7 +9,7 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from bot import bot, dp
-from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK
+from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK, SUPPORT_CHAT_URL
from database import add_connection, get_balance, store_key, update_balance
from handlers.instructions.instructions import send_instructions
from handlers.keys.key_utils import create_key_on_cluster
@@ -202,6 +202,8 @@ async def handle_key_name_input(message: Message, state: FSMContext):
logger.info(f"Generated public link for the key: {public_link}")
+ button_support = InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL)
+
button_profile = InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="view_profile"
)
@@ -221,6 +223,7 @@ async def handle_key_name_input(message: Message, state: FSMContext):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
+ [button_support],
[button_download_ios, button_download_android],
[button_iphone, button_android],
[button_profile],
diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py
index e458fb1a..c2dff394 100644
--- a/handlers/keys/keys.py
+++ b/handlers/keys/keys.py
@@ -56,7 +56,7 @@ async def process_callback_view_keys(callback_query: types.CallbackQuery):
"👇 Выберите устройство для управления подпиской:"
)
- image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg")
+ image_path = os.path.join("img", "pic_keys.jpg")
try:
await bot.delete_message(
@@ -104,7 +104,7 @@ async def process_callback_view_keys(callback_query: types.CallbackQuery):
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
- image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg")
+ image_path = os.path.join("img", "pic_keys.jpg")
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
@@ -231,7 +231,7 @@ async def process_callback_view_key(callback_query: types.CallbackQuery):
keyboard = types.InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
- image_path = os.path.join(os.path.dirname(__file__), "pic_view.jpg")
+ image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await bot.send_message(tg_id, "Файл изображения не найден.")
diff --git a/handlers/keys/pic_keys.jpg b/handlers/keys/pic_keys.jpg
deleted file mode 100644
index a6ec6b6b..00000000
Binary files a/handlers/keys/pic_keys.jpg and /dev/null differ
diff --git a/handlers/pay.py b/handlers/pay.py
index c09cb66e..91757911 100644
--- a/handlers/pay.py
+++ b/handlers/pay.py
@@ -72,8 +72,8 @@ async def handle_pay(callback_query: CallbackQuery):
@router.callback_query(F.data == "back_to_menu")
-async def handle_back_to_menu(callback_query: CallbackQuery):
+async def handle_back_to_menu(callback_query: CallbackQuery, admin: bool = False):
await callback_query.message.delete()
trial_status = await get_trial(callback_query.from_user.id)
- await send_welcome_message(callback_query.from_user.id, trial_status)
+ await send_welcome_message(callback_query.from_user.id, trial_status, admin)
await callback_query.answer()
diff --git a/handlers/payments/robokassa_pay.py b/handlers/payments/robokassa_pay.py
index df91e973..a7809d1f 100644
--- a/handlers/payments/robokassa_pay.py
+++ b/handlers/payments/robokassa_pay.py
@@ -10,7 +10,7 @@ from loguru import logger
from robokassa import HashAlgorithm, Robokassa
from bot import bot
-from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE
+from config import ROBOKASSA_ENABLE, ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE
from database import add_connection, check_connection_exists, get_key_count, update_balance
from handlers.texts import PAYMENT_OPTIONS
@@ -22,15 +22,16 @@ class ReplenishBalanceState(StatesGroup):
waiting_for_payment_confirmation_robokassa = State()
-robokassa = Robokassa(
- merchant_login=ROBOKASSA_LOGIN,
- password1=ROBOKASSA_PASSWORD1,
- password2=ROBOKASSA_PASSWORD2,
- algorithm=HashAlgorithm.md5,
- is_test=ROBOKASSA_TEST_MODE,
-)
+if ROBOKASSA_ENABLE:
+ robokassa = Robokassa(
+ merchant_login=ROBOKASSA_LOGIN,
+ password1=ROBOKASSA_PASSWORD1,
+ password2=ROBOKASSA_PASSWORD2,
+ algorithm=HashAlgorithm.md5,
+ is_test=ROBOKASSA_TEST_MODE,
+ )
-logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
+ logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
def generate_payment_link(amount, inv_id, description):
diff --git a/handlers/payments/stars_pay.py b/handlers/payments/stars_pay.py
index a628d3b0..1f9607f3 100644
--- a/handlers/payments/stars_pay.py
+++ b/handlers/payments/stars_pay.py
@@ -51,6 +51,11 @@ async def process_callback_pay_stars(
tg_id = callback_query.from_user.id
builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(
+ text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
+ )
+ )
for i in range(0, len(PAYMENT_OPTIONS), 2):
if i + 1 < len(PAYMENT_OPTIONS):
@@ -76,11 +81,6 @@ async def process_callback_pay_stars(
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_stars"
)
)
- builder.row(
- InlineKeyboardButton(
- text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
- )
- )
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
key_count = await get_key_count(tg_id)
diff --git a/handlers/profile.py b/handlers/profile.py
index 7958a5b5..a928f103 100644
--- a/handlers/profile.py
+++ b/handlers/profile.py
@@ -15,12 +15,12 @@ router = Router()
async def process_callback_view_profile(
- callback_query: types.CallbackQuery, state: FSMContext
+ callback_query: types.CallbackQuery, state: FSMContext, admin: bool
):
chat_id = callback_query.from_user.id
username = callback_query.from_user.full_name
- image_path = os.path.join(os.path.dirname(__file__), "pic.jpg")
+ image_path = os.path.join("img", "pic.jpg")
try:
key_count = await get_key_count(chat_id)
@@ -52,6 +52,10 @@ async def process_callback_view_profile(
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
)
+ if admin:
+ builder.row(
+ InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")
+ )
builder.row(
InlineKeyboardButton(text="⬅️ Главное меню", callback_data="back_to_menu")
)
@@ -95,7 +99,7 @@ async def invite_handler(callback_query: types.CallbackQuery):
invite_message = invite_message_send(referral_link, referral_stats)
- image_path = os.path.join(os.path.dirname(__file__), "pic_invite.jpg")
+ image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()
builder.row(
@@ -138,6 +142,8 @@ async def invite_handler(callback_query: types.CallbackQuery):
@router.callback_query(F.data == "view_profile")
-async def view_profile_handler(callback_query: types.CallbackQuery, state: FSMContext):
+async def view_profile_handler(
+ callback_query: types.CallbackQuery, state: FSMContext, admin: bool = False
+):
await state.clear()
- await process_callback_view_profile(callback_query, state)
+ await process_callback_view_profile(callback_query, state, admin)
diff --git a/handlers/start.py b/handlers/start.py
index 24833c99..bdee0d5b 100644
--- a/handlers/start.py
+++ b/handlers/start.py
@@ -1,7 +1,6 @@
import os
from aiogram import F, Router
-from aiogram.filters import Command
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -15,8 +14,8 @@ from logger import logger
router = Router()
-async def send_welcome_message(chat_id: int, trial_status: int):
- image_path = os.path.join(os.path.dirname(__file__), "pic.jpg")
+async def send_welcome_message(chat_id: int, trial_status: int, admin: bool):
+ image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder()
if trial_status == 0:
@@ -26,6 +25,10 @@ async def send_welcome_message(chat_id: int, trial_status: int):
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="view_profile")
)
+ if admin:
+ builder.row(
+ InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")
+ )
builder.row(
InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL),
)
@@ -52,8 +55,7 @@ async def send_welcome_message(chat_id: int, trial_status: int):
)
-@router.message(Command("start"))
-async def start_command(message: Message):
+async def start_command(message: Message, admin: bool = False):
logger.info(f"Received start command with text: {message.text}")
if "referral_" in message.text:
referrer_tg_id = int(message.text.split("referral_")[1])
@@ -66,7 +68,7 @@ async def start_command(message: Message):
await message.answer("Вы уже зарегистрированы в системе!")
trial_status = await get_trial(message.from_user.id)
- await send_welcome_message(message.chat.id, trial_status)
+ await send_welcome_message(message.chat.id, trial_status, admin)
@router.callback_query(F.data == "connect_vpn")
@@ -150,8 +152,8 @@ async def handle_about_vpn(callback_query: CallbackQuery):
@router.callback_query(F.data == "back_to_menu")
-async def handle_back_to_menu(callback_query: CallbackQuery):
+async def handle_back_to_menu(callback_query: CallbackQuery, admin: bool = False):
await callback_query.message.delete()
trial_status = await get_trial(callback_query.from_user.id)
- await send_welcome_message(callback_query.from_user.id, trial_status)
+ await send_welcome_message(callback_query.from_user.id, trial_status, admin)
await callback_query.answer()
diff --git a/handlers/utils.py b/handlers/utils.py
index a4cafd30..2eb80d9f 100644
--- a/handlers/utils.py
+++ b/handlers/utils.py
@@ -1,5 +1,6 @@
import random
import re
+from typing import Optional
import asyncpg
@@ -9,35 +10,48 @@ from logger import logger
def sanitize_key_name(key_name: str) -> str:
+ """
+ Очищает название ключа, оставляя только допустимые символы.
+
+ Args:
+ key_name (str): Исходное название ключа.
+
+ Returns:
+ str: Очищенное название ключа в нижнем регистре.
+ """
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
-def generate_random_email():
- """Генерирует случайный набор символов."""
- random_string = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=6))
- return random_string
-
-
-async def get_least_loaded_cluster():
+def generate_random_email(length: int = 6) -> str:
"""
- Функция для получения кластера с наименьшей загрузкой (по количеству ключей).
- Возвращает идентификатор кластера с наименьшей загрузкой или первый кластер из конфигурации,
- если загруженность не определяется. В случае отсутствия кластеров с номером, возвращает 'cluster1'.
+ Генерирует случайный email с заданной длиной.
+
+ Args:
+ length (int, optional): Длина случайной строки. По умолчанию 6.
+
+ Returns:
+ str: Сгенерированная случайная строка.
"""
- cluster_loads = {}
+ return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=length))
- conn = await asyncpg.connect(DATABASE_URL)
- try:
- keys = await conn.fetch("SELECT * FROM keys")
- for key in keys:
- cluster_id = key["server_id"]
- if re.match(r"^cluster\d+$", cluster_id):
- if cluster_id not in cluster_loads:
- cluster_loads[cluster_id] = 0
- cluster_loads[cluster_id] += 1
- finally:
- await conn.close()
+async def get_least_loaded_cluster() -> str:
+ """
+ Определяет кластер с наименьшей загрузкой.
+
+ Returns:
+ str: Идентификатор наименее загруженного кластера.
+ """
+ cluster_loads: dict[str, int] = {}
+
+ async with asyncpg.create_pool(DATABASE_URL) as pool:
+ async with pool.acquire() as conn:
+ keys = await conn.fetch("SELECT * FROM keys")
+
+ for key in keys:
+ cluster_id = key["server_id"]
+ if re.match(r"^cluster\d+$", cluster_id):
+ cluster_loads[cluster_id] = cluster_loads.get(cluster_id, 0) + 1
logger.info(f"Cluster loads: {cluster_loads}")
@@ -51,29 +65,38 @@ async def get_least_loaded_cluster():
logger.info(f"Available clusters from config: {available_clusters}")
if available_clusters:
- logger.info(
- f"Returning the first available cluster: {available_clusters[0]}"
- )
- return available_clusters[0]
- else:
- logger.warning("No valid clusters found in config, returning 'cluster1'.")
- return "cluster1"
+ selected_cluster = available_clusters[0]
+ logger.info(f"Returning the first available cluster: {selected_cluster}")
+ return selected_cluster
+
+ logger.warning("No valid clusters found in config, returning 'cluster1'.")
+ return "cluster1"
least_loaded_cluster = min(cluster_loads, key=cluster_loads.get)
-
logger.info(f"Least loaded cluster selected: {least_loaded_cluster}")
return least_loaded_cluster
-async def handle_error(tg_id, callback_query, message):
+async def handle_error(
+ tg_id: int, callback_query: Optional[object] = None, message: str = ""
+) -> None:
+ """
+ Обрабатывает ошибку, отправляя сообщение пользователю.
+
+ Args:
+ tg_id (int): Идентификатор пользователя в Telegram.
+ callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
+ message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
+ """
try:
- try:
- await bot.delete_message(
- chat_id=tg_id, message_id=callback_query.message.message_id
- )
- except Exception:
- pass
+ if callback_query and hasattr(callback_query, "message"):
+ try:
+ await bot.delete_message(
+ chat_id=tg_id, message_id=callback_query.message.message_id
+ )
+ except Exception as delete_error:
+ logger.warning(f"Не удалось удалить сообщение: {delete_error}")
await bot.send_message(tg_id, message, parse_mode="HTML")
diff --git a/handlers/keys/pic_view.jpg b/img/instructions.jpg
similarity index 100%
rename from handlers/keys/pic_view.jpg
rename to img/instructions.jpg
diff --git a/handlers/pic.jpg b/img/pic.jpg
similarity index 100%
rename from handlers/pic.jpg
rename to img/pic.jpg
diff --git a/handlers/pic_invite.jpg b/img/pic_invite.jpg
similarity index 100%
rename from handlers/pic_invite.jpg
rename to img/pic_invite.jpg
diff --git a/img/pic_keys.jpg b/img/pic_keys.jpg
new file mode 100644
index 00000000..1064f788
Binary files /dev/null and b/img/pic_keys.jpg differ
diff --git a/img/pic_view.jpg b/img/pic_view.jpg
new file mode 100644
index 00000000..1064f788
Binary files /dev/null and b/img/pic_view.jpg differ
diff --git a/middlewares/admin.py b/middlewares/admin.py
new file mode 100644
index 00000000..0ae5f51a
--- /dev/null
+++ b/middlewares/admin.py
@@ -0,0 +1,29 @@
+from typing import Any, Awaitable, Callable, Dict,Union
+
+from aiogram import BaseMiddleware
+from aiogram.types import TelegramObject
+
+from config import ADMIN_ID
+from logger import logger
+
+
+class AdminMiddleware(BaseMiddleware):
+ async def __call__(
+ self,
+ handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
+ event: TelegramObject,
+ data: Dict[str, Any],
+ ) -> Any:
+ data["admin"] = self._check_admin_access(event)
+ return await handler(event, data)
+
+ def _check_admin_access(self, event: TelegramObject) -> bool:
+ try:
+ admin_ids: Union[int, list[int]] = ADMIN_ID
+
+ if isinstance(admin_ids, list):
+ return event.from_user.id in admin_ids
+ return event.from_user.id == admin_ids
+ except Exception as e:
+ logger.error(f"Ошибка проверки администратора: {e}")
+ return False
diff --git a/middlewares/database.py b/middlewares/database.py
index 98482283..0244a0d8 100644
--- a/middlewares/database.py
+++ b/middlewares/database.py
@@ -14,9 +14,10 @@ class DatabaseMiddleware(BaseMiddleware):
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
- session = await asyncpg.connect(DATABASE_URL)
- data["session"] = session
- try:
- return await handler(event, data)
- finally:
- await session.close()
+ async with await asyncpg.create_pool(DATABASE_URL) as pool:
+ async with pool.acquire() as session:
+ data["session"] = session
+ try:
+ return await handler(event, data)
+ finally:
+ await pool.release(session)
diff --git a/middlewares/logging.py b/middlewares/logging.py
index 9325a555..fbe31bae 100644
--- a/middlewares/logging.py
+++ b/middlewares/logging.py
@@ -1,4 +1,4 @@
-from typing import Any, Awaitable, Callable, Dict
+from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
@@ -13,23 +13,30 @@ class LoggingMiddleware(BaseMiddleware):
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
+ user_info = self._extract_user_info(event)
+
+ logger.info(
+ f"Активность пользователя - "
+ f"ID пользователя: {user_info['user_id']}, "
+ f"Имя пользователя: {user_info['username']}, "
+ f"Действие: {user_info['action']}"
+ )
+ return await handler(event, data)
+
+ def _extract_user_info(self, event: TelegramObject) -> Dict[str, Optional[str]]:
user_id = None
username = None
action = None
if isinstance(event, Message):
- user_id = event.from_user.id
- username = event.from_user.username
+ user = event.from_user
+ user_id = user.id
+ username = user.username
action = f"Сообщение: {event.text}"
elif isinstance(event, CallbackQuery):
- user_id = event.from_user.id
- username = event.from_user.username
+ user = event.from_user
+ user_id = user.id
+ username = user.username
action = f"Обратный вызов: {event.data}"
- logger.info(
- f"Активность пользователя - "
- f"ID пользователя: {user_id}, "
- f"Имя пользователя: {username}, "
- f"Действие: {action}"
- )
- return await handler(event, data)
+ return {"user_id": user_id, "username": username, "action": action}
diff --git a/middlewares/user.py b/middlewares/user.py
new file mode 100644
index 00000000..969020ef
--- /dev/null
+++ b/middlewares/user.py
@@ -0,0 +1,28 @@
+from typing import Any, Awaitable, Callable, Dict
+
+from aiogram import BaseMiddleware
+from aiogram.types import TelegramObject, User
+
+from database import upsert_user
+
+
+class UserMiddleware(BaseMiddleware):
+ async def __call__(
+ self,
+ handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
+ event: TelegramObject,
+ data: Dict[str, Any],
+ ) -> Any:
+ if user := data.get("event_from_user"):
+ await self._process_user(user)
+ return await handler(event, data)
+
+ async def _process_user(self, user: User) -> None:
+ await upsert_user(
+ tg_id=user.id,
+ username=user.username,
+ first_name=user.first_name,
+ last_name=user.last_name,
+ language_code=user.language_code,
+ is_bot=user.is_bot,
+ )
diff --git a/models.py b/models.py
deleted file mode 100644
index ee997b3a..00000000
--- a/models.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from sqlalchemy import BigInteger, Boolean, Column, Float, ForeignKey, Text
-from sqlalchemy.orm import declarative_base, relationship
-
-Base = declarative_base()
-
-
-class Connection(Base):
- """
- Модель для таблицы connections
- """
-
- __tablename__ = "connections"
-
- tg_id = Column(BigInteger, primary_key=True, nullable=False)
- balance = Column(Float, nullable=False, default=0.0)
- trial = Column(BigInteger, nullable=False, default=0)
-
- # Связь с ключами и рефералами
- keys = relationship("Key", back_populates="connection")
- referrals_received = relationship(
- "Referral", foreign_keys="Referral.referred_tg_id", back_populates="referred"
- )
- referrals_sent = relationship(
- "Referral", foreign_keys="Referral.referrer_tg_id", back_populates="referrer"
- )
-
-
-class Key(Base):
- """
- Модель для таблицы keys
- """
-
- __tablename__ = "keys"
-
- tg_id = Column(
- BigInteger, ForeignKey("connections.tg_id"), primary_key=True, nullable=False
- )
- client_id = Column(Text, primary_key=True, nullable=False)
- email = Column(Text, nullable=False)
- created_at = Column(BigInteger, nullable=False)
- expiry_time = Column(BigInteger, nullable=False)
- key = Column(Text, nullable=False)
- server_id = Column(Text, nullable=False, default="server1")
- notified = Column(Boolean, nullable=False, default=False)
- notified_24h = Column(Boolean, nullable=False, default=False)
-
- # Связь с подключением
- connection = relationship("Connection", back_populates="keys")
-
-
-class Referral(Base):
- """
- Модель для таблицы referrals
- """
-
- __tablename__ = "referrals"
-
- referred_tg_id = Column(
- BigInteger, ForeignKey("connections.tg_id"), primary_key=True, nullable=False
- )
- referrer_tg_id = Column(BigInteger, ForeignKey("connections.tg_id"), nullable=False)
- reward_issued = Column(Boolean, default=False)
-
- # Связи с подключениями
- referred = relationship(
- "Connection", foreign_keys=[referred_tg_id], back_populates="referrals_received"
- )
- referrer = relationship(
- "Connection", foreign_keys=[referrer_tg_id], back_populates="referrals_sent"
- )
diff --git a/requirements.txt b/requirements.txt
index 262fc7fb..e3e0f098 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -28,4 +28,5 @@ yookassa==3.3.0
loguru
aiocryptopay
py3xui
-sqlalchemy
\ No newline at end of file
+sqlalchemy
+robokassa
\ No newline at end of file
diff --git a/scripts/node.sh b/scripts/node.sh
deleted file mode 100644
index aee41eb3..00000000
--- a/scripts/node.sh
+++ /dev/null
@@ -1,164 +0,0 @@
-#!/bin/bash
-
-# Цвета для вывода
-GREEN='\033[0;32m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-echo -e "${BLUE}3x-ui Installation Script by izzzzzi${NC}"
-echo "----------------------------------------"
-
-# Запрос данных у пользователя
-read -p "Enter your domain (e.g., example.com): " DOMAIN
-read -p "Enter desired admin panel port (default: 2053): " PANEL_PORT
-read -p "Enter desired sub panel port (default: 2054): " SUB_PANEL_PORT
-
-# Использование значения по умолчанию для порта, если не указано
-PANEL_PORT=${PANEL_PORT:-2053}
-SUB_PANEL_PORT=${SUB_PANEL_PORT:-2054}
-
-# Создание необходимых директорий
-echo -e "${GREEN}Creating directories...${NC}"
-mkdir -p nginx/conf.d db cert
-
-# Установка certbot
-echo -e "${GREEN}Installing certbot...${NC}"
-apt-get update
-apt-get install certbot -y
-
-# Остановка nginx если он запущен
-echo -e "${GREEN}Stopping nginx if running...${NC}"
-docker-compose down 2>/dev/null
-systemctl stop nginx 2>/dev/null
-
-# Получение сертификата
-echo -e "${GREEN}Obtaining SSL certificate...${NC}"
-certbot certonly --standalone --agree-tos --register-unsafely-without-email -d $DOMAIN
-
-# Проверка успешности получения сертификата
-if [ ! -d "/etc/letsencrypt/live/$DOMAIN" ]; then
- echo "Failed to obtain SSL certificate. Please check your domain settings."
- exit 1
-fi
-
-# Тестирование автообновления
-echo -e "${GREEN}Testing certificate renewal...${NC}"
-certbot renew --dry-run
-
-# Создание docker-compose.yml
-echo -e "${GREEN}Creating docker-compose.yml...${NC}"
-cat > docker-compose.yml << EOL
-version: "3"
-
-services:
- nginx:
- image: nginx:alpine
- container_name: nginx
- ports:
- - "80:80"
- - "443:443"
- volumes:
- - ./nginx/conf.d:/etc/nginx/conf.d
- - /etc/letsencrypt:/etc/letsencrypt
- - /var/lib/letsencrypt:/var/lib/letsencrypt
- networks:
- - proxy-network
- restart: unless-stopped
- depends_on:
- - 3x-ui
-
- 3x-ui:
- image: ghcr.io/mhsanaei/3x-ui:latest
- container_name: 3x-ui
- hostname: ${DOMAIN}
- volumes:
- - \$PWD/db/:/etc/x-ui/
- - \$PWD/cert/:/root/cert/
- - /etc/letsencrypt:/etc/letsencrypt:ro
- environment:
- XRAY_VMESS_AEAD_FORCED: "false"
- tty: true
- networks:
- - proxy-network
- restart: unless-stopped
-
-networks:
- proxy-network:
- driver: bridge
-EOL
-
-# Создание конфигурации Nginx
-echo -e "${GREEN}Creating Nginx configuration...${NC}"
-cat > nginx/conf.d/default.conf << EOL
-server {
- listen 80;
- server_name ${DOMAIN};
-
- location / {
- return 301 https://\$host\$request_uri;
- }
-}
-
-server {
- listen 443 ssl;
- server_name ${DOMAIN};
-
- ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
-
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_prefer_server_ciphers off;
- ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
-
- location / {
- proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto \$scheme;
- proxy_set_header Host \$http_host;
- proxy_set_header X-Real-IP \$remote_addr;
- proxy_set_header Range \$http_range;
- proxy_set_header If-Range \$http_if_range;
- proxy_redirect off;
- proxy_pass http://3x-ui:${PANEL_PORT};
- }
-
- location /sub {
- proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto \$scheme;
- proxy_set_header Host \$http_host;
- proxy_set_header X-Real-IP \$remote_addr;
- proxy_set_header Range \$http_range;
- proxy_set_header If-Range \$http_if_range;
- proxy_redirect off;
- proxy_pass http://3x-ui:${SUB_PANEL_PORT};
- }
-}
-EOL
-
-# Проверка наличия Docker и Docker Compose
-if ! command -v docker &> /dev/null || ! command -v docker-compose &> /dev/null; then
- echo -e "${GREEN}Installing Docker and Docker Compose...${NC}"
- curl -fsSL https://get.docker.com | sh
- curl -L "https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
- chmod +x /usr/local/bin/docker-compose
-fi
-
-# Настройка автообновления сертификатов
-echo -e "${GREEN}Setting up certificate auto-renewal...${NC}"
-cat > /etc/cron.d/certbot-renew << EOL
-0 */12 * * * root certbot renew --quiet --deploy-hook "docker-compose -f $(pwd)/docker-compose.yml restart nginx"
-EOL
-
-# Запуск сервисов
-echo -e "${GREEN}Starting all services...${NC}"
-docker-compose up -d
-
-echo -e "${GREEN}Installation completed!${NC}"
-echo -e "${BLUE}You can access your 3x-ui panel at: https://${DOMAIN}${NC}"
-echo -e "${YELLOW}Important: Default access to 3x-ui panel - ${NC}"
-echo -e "${YELLOW}Login: admin${NC}"
-echo -e "${YELLOW}Password: admin${NC}"
-echo -e "${YELLOW}For security, it is recommended to change the password at: https://${DOMAIN}/panel/settings${NC}"
-echo "Please wait a few minutes for all services to start properly."
-echo "Default credentials can be found in the 3x-ui documentation."
-
-EOL
\ No newline at end of file
diff --git a/scripts/postgres.sh b/scripts/postgres.sh
deleted file mode 100644
index ed77314b..00000000
--- a/scripts/postgres.sh
+++ /dev/null
@@ -1,108 +0,0 @@
-#!/bin/bash
-
-# Цвета для вывода
-GREEN='\033[0;32m'
-BLUE='\033[0;34m'
-RED='\033[0;31m'
-NC='\033[0m' # No Color
-
-echo -e "${BLUE}PostgreSQL Installation and Setup Script by izzzzzi${NC}"
-echo "----------------------------------------"
-
-# Запрос данных у пользователя
-read -p "Enter PostgreSQL port (default: 5432): " DB_PORT
-read -p "Enter database name: " DB_NAME
-read -p "Enter database user: " DB_USER
-read -s -p "Enter database password: " DB_PASS
-echo
-read -s -p "Confirm database password: " DB_PASS_CONFIRM
-echo
-
-# Проверка паролей
-if [ "$DB_PASS" != "$DB_PASS_CONFIRM" ]; then
- echo -e "${RED}Passwords do not match!${NC}"
- exit 1
-fi
-
-# Использование значения по умолчанию для порта
-DB_PORT=${DB_PORT:-5432}
-
-# Установка PostgreSQL
-echo -e "${GREEN}Installing PostgreSQL...${NC}"
-apt-get update
-apt-get install -y postgresql postgresql-contrib
-
-# Остановка PostgreSQL для изменения конфигурации
-systemctl stop postgresql
-
-# Настройка PostgreSQL для внешних подключений
-echo -e "${GREEN}Configuring PostgreSQL...${NC}"
-
-# Настройка postgresql.conf
-PG_VERSION=$(ls /etc/postgresql/)
-PG_CONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf"
-PG_HBA="/etc/postgresql/$PG_VERSION/main/pg_hba.conf"
-
-# Backup конфигурационных файлов
-cp $PG_CONF "${PG_CONF}.backup"
-cp $PG_HBA "${PG_HBA}.backup"
-
-# Изменение postgresql.conf
-sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" $PG_CONF
-sed -i "s/#port = 5432/port = $DB_PORT/" $PG_CONF
-
-# Изменение pg_hba.conf
-cat > $PG_HBA << EOL
-# TYPE DATABASE USER ADDRESS METHOD
-local all postgres peer
-local all all peer
-host all all 127.0.0.1/32 md5
-host all all ::1/128 md5
-host all all 0.0.0.0/0 md5
-EOL
-
-# Запуск PostgreSQL
-systemctl start postgresql
-systemctl enable postgresql
-
-# Создание пользователя и базы данных
-echo -e "${GREEN}Creating database and user...${NC}"
-sudo -u postgres psql << EOF
-CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';
-CREATE DATABASE $DB_NAME OWNER $DB_USER;
-ALTER USER $DB_USER WITH SUPERUSER;
-EOF
-
-# Настройка файрвола
-echo -e "${GREEN}Configuring firewall...${NC}"
-if command -v ufw >/dev/null; then
- ufw allow $DB_PORT/tcp
- ufw status
-fi
-
-# Проверка статуса PostgreSQL
-systemctl status postgresql --no-pager
-
-echo -e "${GREEN}Installation completed!${NC}"
-echo -e "${BLUE}PostgreSQL is running on port: ${DB_PORT}${NC}"
-echo -e "${BLUE}Database name: ${DB_NAME}${NC}"
-echo -e "${BLUE}Database user: ${DB_USER}${NC}"
-echo "You can now connect to your database using:"
-echo "psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME"
-echo
-echo "For remote connections use:"
-echo "psql -h YOUR_SERVER_IP -p $DB_PORT -U $DB_USER -d $DB_NAME"
-echo
-echo -e "${RED}Important: Make sure to save these credentials in a secure place!${NC}"
-
-# Проверка подключения
-echo -e "${GREEN}Testing connection...${NC}"
-PGPASSWORD=$DB_PASS psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME -c "\conninfo"
-
-# Добавление информации о создании бэкапов
-echo
-echo "To create a backup, use:"
-echo "pg_dump -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME > backup.sql"
-echo
-echo "To restore from backup, use:"
-echo "psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME < backup.sql"
\ No newline at end of file