runtime settings loaded from DB/ refactoring admin-panel/ minor fixes and improvements
This commit is contained in:
@@ -1,18 +1,11 @@
|
|||||||
import traceback
|
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
|
||||||
from aiogram.filters import ExceptionTypeFilter
|
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
|
||||||
from aiogram.utils.markdown import hbold
|
|
||||||
|
|
||||||
from config import ADMIN_ID, API_TOKEN
|
from config import API_TOKEN
|
||||||
from database import async_session_maker
|
|
||||||
from filters.private import IsPrivateFilter
|
from filters.private import IsPrivateFilter
|
||||||
from logger import logger
|
from utils.errors import setup_error_handlers
|
||||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||||
|
|
||||||
|
|
||||||
@@ -27,125 +20,4 @@ load_modules_from_folder()
|
|||||||
dp.message.filter(IsPrivateFilter())
|
dp.message.filter(IsPrivateFilter())
|
||||||
dp.callback_query.filter(IsPrivateFilter())
|
dp.callback_query.filter(IsPrivateFilter())
|
||||||
|
|
||||||
|
setup_error_handlers(dp)
|
||||||
@dp.errors(ExceptionTypeFilter(Exception))
|
|
||||||
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
|
||||||
if isinstance(event.exception, TelegramForbiddenError):
|
|
||||||
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if isinstance(event.exception, TelegramBadRequest):
|
|
||||||
error_message = str(event.exception)
|
|
||||||
|
|
||||||
if (
|
|
||||||
"query is too old and response timeout expired or query ID is invalid" in error_message
|
|
||||||
or "message can't be deleted for everyone" in error_message
|
|
||||||
or "message to delete not found" in error_message
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
tb = "".join(
|
|
||||||
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
|
|
||||||
)
|
|
||||||
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
|
|
||||||
logger.error(f"Traceback:\n{tb}")
|
|
||||||
|
|
||||||
if ADMIN_ID:
|
|
||||||
for admin_id in ADMIN_ID:
|
|
||||||
await bot.send_document(
|
|
||||||
chat_id=admin_id,
|
|
||||||
document=BufferedInputFile(
|
|
||||||
tb.encode(),
|
|
||||||
filename=f"error_{event.update.update_id}.txt",
|
|
||||||
),
|
|
||||||
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from handlers.start import start_entry
|
|
||||||
|
|
||||||
if event.update.message:
|
|
||||||
fsm_context = dp.fsm.get_context(
|
|
||||||
bot=bot,
|
|
||||||
chat_id=event.update.message.chat.id,
|
|
||||||
user_id=event.update.message.from_user.id,
|
|
||||||
)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await start_entry(
|
|
||||||
event=event.update.message,
|
|
||||||
state=fsm_context,
|
|
||||||
session=session,
|
|
||||||
admin=False,
|
|
||||||
captcha=False,
|
|
||||||
)
|
|
||||||
elif event.update.callback_query:
|
|
||||||
fsm_context = dp.fsm.get_context(
|
|
||||||
bot=bot,
|
|
||||||
chat_id=event.update.callback_query.message.chat.id,
|
|
||||||
user_id=event.update.callback_query.from_user.id,
|
|
||||||
)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await start_entry(
|
|
||||||
event=event.update.callback_query,
|
|
||||||
state=fsm_context,
|
|
||||||
session=session,
|
|
||||||
admin=False,
|
|
||||||
captcha=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
|
||||||
|
|
||||||
if not ADMIN_ID:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
for admin_id in ADMIN_ID:
|
|
||||||
await bot.send_document(
|
|
||||||
chat_id=admin_id,
|
|
||||||
document=BufferedInputFile(
|
|
||||||
traceback.format_exc().encode(),
|
|
||||||
filename=f"error_{event.update.update_id}.txt",
|
|
||||||
),
|
|
||||||
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
|
||||||
)
|
|
||||||
|
|
||||||
if event.update.message:
|
|
||||||
fsm_context = dp.fsm.get_context(
|
|
||||||
bot=bot,
|
|
||||||
chat_id=event.update.message.chat.id,
|
|
||||||
user_id=event.update.message.from_user.id,
|
|
||||||
)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await start_entry(
|
|
||||||
event=event.update.message,
|
|
||||||
state=fsm_context,
|
|
||||||
session=session,
|
|
||||||
admin=False,
|
|
||||||
captcha=False,
|
|
||||||
)
|
|
||||||
elif event.update.callback_query:
|
|
||||||
fsm_context = dp.fsm.get_context(
|
|
||||||
bot=bot,
|
|
||||||
chat_id=event.update.callback_query.message.chat.id,
|
|
||||||
user_id=event.update.callback_query.from_user.id,
|
|
||||||
)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await start_entry(
|
|
||||||
event=event.update.callback_query,
|
|
||||||
state=fsm_context,
|
|
||||||
session=session,
|
|
||||||
admin=False,
|
|
||||||
captcha=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
except TelegramBadRequest as exception:
|
|
||||||
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
|
||||||
except Exception as exception:
|
|
||||||
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
from database import async_session_maker
|
||||||
|
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
|
||||||
|
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||||
|
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
|
||||||
|
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||||
|
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
|
||||||
|
from .settings.management_config import MANAGEGENT_CONFIG, load_management_config, update_management_config
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap() -> None:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await load_buttons_config(session)
|
||||||
|
await load_notifications_config(session)
|
||||||
|
await load_modes_config(session)
|
||||||
|
await load_payments_config(session)
|
||||||
|
await load_money_config(session)
|
||||||
|
await load_management_config(session)
|
||||||
|
await session.commit()
|
||||||
Binary file not shown.
@@ -0,0 +1,56 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_BUTTONS_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_buttons_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="BUTTONS_CONFIG",
|
||||||
|
value=buttons_config,
|
||||||
|
description="Конфигурация кнопок бота",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||||
|
buttons_config.update(stored)
|
||||||
|
setting.value = buttons_config
|
||||||
|
|
||||||
|
BUTTONS_CONFIG.clear()
|
||||||
|
BUTTONS_CONFIG.update(buttons_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_buttons_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="BUTTONS_CONFIG",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация кнопок бота",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||||
|
buttons_config.update(new_values)
|
||||||
|
|
||||||
|
BUTTONS_CONFIG.clear()
|
||||||
|
BUTTONS_CONFIG.update(buttons_config)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
MANAGEGENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_management_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "management")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="management",
|
||||||
|
value=management_config,
|
||||||
|
description="Конфигурация управления ботом",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||||
|
management_config.update(stored)
|
||||||
|
setting.value = management_config
|
||||||
|
|
||||||
|
MANAGEGENT_CONFIG.clear()
|
||||||
|
MANAGEGENT_CONFIG.update(management_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "management")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="management",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация управления ботом",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||||
|
management_config.update(new_values)
|
||||||
|
|
||||||
|
MANAGEGENT_CONFIG.clear()
|
||||||
|
MANAGEGENT_CONFIG.update(management_config)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_MODES_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
MODES_CONFIG: dict[str, bool] = DEFAULT_MODES_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_modes_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="MODES_CONFIG",
|
||||||
|
value=modes_config,
|
||||||
|
description="Конфигурация режимов работы бота",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||||
|
modes_config.update(stored)
|
||||||
|
setting.value = modes_config
|
||||||
|
|
||||||
|
MODES_CONFIG.clear()
|
||||||
|
MODES_CONFIG.update(modes_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="MODES_CONFIG",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация режимов работы бота",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||||
|
modes_config.update(new_values)
|
||||||
|
|
||||||
|
MODES_CONFIG.clear()
|
||||||
|
MODES_CONFIG.update(modes_config)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_MONEY_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
MONEY_CONFIG: dict[str, Any] = DEFAULT_MONEY_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_money_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="MONEY_CONFIG",
|
||||||
|
value=money_config,
|
||||||
|
description="Конфигурация валютных настроек",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||||
|
money_config.update(stored)
|
||||||
|
setting.value = money_config
|
||||||
|
|
||||||
|
MONEY_CONFIG.clear()
|
||||||
|
MONEY_CONFIG.update(money_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="MONEY_CONFIG",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация валютных настроек",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||||
|
money_config.update(new_values)
|
||||||
|
|
||||||
|
MONEY_CONFIG.clear()
|
||||||
|
MONEY_CONFIG.update(money_config)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATIONS_CONFIG: dict[str, Any] = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_notifications_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="NOTIFICATIONS_CONFIG",
|
||||||
|
value=notifications_config,
|
||||||
|
description="Конфигурация уведомлений",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||||
|
notifications_config.update(stored)
|
||||||
|
setting.value = notifications_config
|
||||||
|
|
||||||
|
NOTIFICATIONS_CONFIG.clear()
|
||||||
|
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_notifications_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="NOTIFICATIONS_CONFIG",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация уведомлений",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||||
|
notifications_config.update(new_values)
|
||||||
|
|
||||||
|
NOTIFICATIONS_CONFIG.clear()
|
||||||
|
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Setting
|
||||||
|
from ..defaults import DEFAULT_PAYMENTS_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
PAYMENTS_CONFIG: dict[str, bool] = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_payments_config(session: AsyncSession) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||||
|
setting = Setting(
|
||||||
|
key="PAYMENTS_CONFIG",
|
||||||
|
value=payments_config,
|
||||||
|
description="Конфигурация платёжных провайдеров",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
stored = setting.value or {}
|
||||||
|
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||||
|
payments_config.update(stored)
|
||||||
|
setting.value = payments_config
|
||||||
|
|
||||||
|
PAYMENTS_CONFIG.clear()
|
||||||
|
PAYMENTS_CONFIG.update(payments_config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_payments_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||||
|
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(
|
||||||
|
key="PAYMENTS_CONFIG",
|
||||||
|
value=new_values,
|
||||||
|
description="Конфигурация платёжных провайдеров",
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = new_values
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||||
|
payments_config.update(new_values)
|
||||||
|
|
||||||
|
PAYMENTS_CONFIG.clear()
|
||||||
|
PAYMENTS_CONFIG.update(payments_config)
|
||||||
+2
-2
@@ -23,7 +23,7 @@ async def store_key(
|
|||||||
try:
|
try:
|
||||||
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
||||||
existing_key = exists.scalar_one_or_none()
|
existing_key = exists.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_key:
|
if existing_key:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(Key)
|
update(Key)
|
||||||
@@ -54,7 +54,7 @@ async def store_key(
|
|||||||
)
|
)
|
||||||
session.add(new_key)
|
session.add(new_key)
|
||||||
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
|
|||||||
@@ -262,3 +262,13 @@ class Admin(Base):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_token() -> str:
|
def generate_token() -> str:
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key = Column(String, primary_key=True)
|
||||||
|
value = Column(JSONB, nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .models import Setting
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(session: AsyncSession, key: str, default: Any = None) -> Any:
|
||||||
|
stmt = select(Setting).where(Setting.key == key)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
if setting is None or setting.value is None:
|
||||||
|
return default
|
||||||
|
return setting.value
|
||||||
|
|
||||||
|
|
||||||
|
async def set_setting(
|
||||||
|
session: AsyncSession,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> Setting:
|
||||||
|
stmt = select(Setting).where(Setting.key == key)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = Setting(key=key, value=value, description=description)
|
||||||
|
session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = value
|
||||||
|
if description is not None:
|
||||||
|
setting.description = description
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
return setting
|
||||||
@@ -36,7 +36,7 @@ async def count_active_keys(session: AsyncSession) -> int:
|
|||||||
async def count_active_paid_keys(session: AsyncSession) -> int:
|
async def count_active_paid_keys(session: AsyncSession) -> int:
|
||||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||||
|
|
||||||
return await session.scalar(
|
return await session.scalar(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Key)
|
.select_from(Key)
|
||||||
@@ -48,7 +48,7 @@ async def count_active_paid_keys(session: AsyncSession) -> int:
|
|||||||
async def count_active_trial_keys(session: AsyncSession) -> int:
|
async def count_active_trial_keys(session: AsyncSession) -> int:
|
||||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||||
|
|
||||||
return await session.scalar(
|
return await session.scalar(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Key)
|
.select_from(Key)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .panel import router as panel_router
|
|||||||
from .restart import router as restart_router
|
from .restart import router as restart_router
|
||||||
from .sender import router as sender_router
|
from .sender import router as sender_router
|
||||||
from .servers import router as servers_router
|
from .servers import router as servers_router
|
||||||
|
from .settings import router as settings_router
|
||||||
from .stats import router as stats_router
|
from .stats import router as stats_router
|
||||||
from .tariffs import router as tariffs_router
|
from .tariffs import router as tariffs_router
|
||||||
from .users import router as users_router
|
from .users import router as users_router
|
||||||
@@ -37,4 +38,5 @@ router.include_routers(
|
|||||||
ads_router,
|
ads_router,
|
||||||
tariffs_router,
|
tariffs_router,
|
||||||
gifts_router,
|
gifts_router,
|
||||||
|
settings_router,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from database.models import BlockedUser, Key, ManualBan
|
|||||||
from filters.admin import IsAdminFilter
|
from filters.admin import IsAdminFilter
|
||||||
from logger import logger
|
from logger import logger
|
||||||
|
|
||||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
from .keyboard import (
|
from .keyboard import (
|
||||||
build_bans_kb,
|
build_bans_kb,
|
||||||
build_blocked_users_kb,
|
build_blocked_users_kb,
|
||||||
@@ -69,19 +69,12 @@ def get_shadow_bans_menu_text() -> str:
|
|||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
|
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
|
||||||
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
|
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||||
text=get_shadow_bans_menu_text(),
|
|
||||||
reply_markup=build_shadow_bans_kb()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
|
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
|
||||||
async def handle_manual_bans_menu(callback_query: CallbackQuery):
|
async def handle_manual_bans_menu(callback_query: CallbackQuery):
|
||||||
text_ = (
|
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
|
||||||
"🔒 <b>Ручные баны</b>\n\n"
|
|
||||||
"Пользователи, которых вы забанили через админку.\n"
|
|
||||||
"⬇ Выберите действие:"
|
|
||||||
)
|
|
||||||
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
|
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
|
||||||
|
|
||||||
|
|
||||||
@@ -117,11 +110,7 @@ async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSessio
|
|||||||
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
|
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
kb = build_blocked_users_kb()
|
kb = build_blocked_users_kb()
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
|
||||||
select(BlockedUser.tg_id)
|
|
||||||
.outerjoin(Key, BlockedUser.tg_id == Key.tg_id)
|
|
||||||
.where(Key.tg_id.is_(None))
|
|
||||||
)
|
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
blocked_ids = [row[0] for row in result.all()]
|
blocked_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
@@ -151,8 +140,9 @@ async def handle_shadow_bans_export(callback_query: CallbackQuery, session: Asyn
|
|||||||
kb = build_shadow_bans_kb()
|
kb = build_shadow_bans_kb()
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until)
|
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
|
||||||
.where(ManualBan.reason == "shadow")
|
ManualBan.reason == "shadow"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
@@ -182,8 +172,9 @@ async def handle_manual_bans_export(callback_query: CallbackQuery, session: Asyn
|
|||||||
kb = build_manual_bans_kb()
|
kb = build_manual_bans_kb()
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by)
|
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
|
||||||
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
@@ -214,7 +205,7 @@ async def handle_clear_blocked_users(callback_query: CallbackQuery, session: Asy
|
|||||||
try:
|
try:
|
||||||
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
|
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
|
||||||
total_count = count_result.scalar() or 0
|
total_count = count_result.scalar() or 0
|
||||||
|
|
||||||
if total_count == 0:
|
if total_count == 0:
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text="📂 Нет забанивших пользователей для очистки.",
|
text="📂 Нет забанивших пользователей для очистки.",
|
||||||
@@ -224,7 +215,7 @@ async def handle_clear_blocked_users(callback_query: CallbackQuery, session: Asy
|
|||||||
|
|
||||||
await session.execute(delete(BlockedUser))
|
await session.execute(delete(BlockedUser))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
|
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
@@ -246,7 +237,7 @@ async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: Async
|
|||||||
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
|
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
|
||||||
)
|
)
|
||||||
total_count = count_result.scalar() or 0
|
total_count = count_result.scalar() or 0
|
||||||
|
|
||||||
if total_count == 0:
|
if total_count == 0:
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text="📂 Нет теневых банов для очистки.",
|
text="📂 Нет теневых банов для очистки.",
|
||||||
@@ -256,7 +247,7 @@ async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: Async
|
|||||||
|
|
||||||
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
|
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
|
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
@@ -275,12 +266,12 @@ async def handle_clear_manual_bans(callback_query: CallbackQuery, session: Async
|
|||||||
kb = build_manual_bans_kb()
|
kb = build_manual_bans_kb()
|
||||||
try:
|
try:
|
||||||
count_result = await session.execute(
|
count_result = await session.execute(
|
||||||
select(func.count()).select_from(ManualBan).where(
|
select(func.count())
|
||||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
.select_from(ManualBan)
|
||||||
)
|
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
||||||
)
|
)
|
||||||
total_count = count_result.scalar() or 0
|
total_count = count_result.scalar() or 0
|
||||||
|
|
||||||
if total_count == 0:
|
if total_count == 0:
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text="📂 Нет ручных банов для очистки.",
|
text="📂 Нет ручных банов для очистки.",
|
||||||
@@ -288,13 +279,9 @@ async def handle_clear_manual_bans(callback_query: CallbackQuery, session: Async
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await session.execute(
|
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
|
||||||
delete(ManualBan).where(
|
|
||||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await callback_query.message.answer(
|
await callback_query.message.answer(
|
||||||
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
|
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
@@ -327,10 +314,7 @@ async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext
|
|||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
|
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
|
||||||
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
|
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||||
text=get_shadow_bans_menu_text(),
|
|
||||||
reply_markup=build_shadow_bans_kb()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
|
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
__all__ = ("router",)
|
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa
|
||||||
|
from .base import AdminClusterStates, router
|
||||||
|
|
||||||
from .clusters_handler import router
|
|
||||||
|
__all__ = ["router", "AdminClusterStates"]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminClusterStates(StatesGroup):
|
||||||
|
waiting_for_cluster_name = State()
|
||||||
|
waiting_for_api_url = State()
|
||||||
|
waiting_for_inbound_id = State()
|
||||||
|
waiting_for_server_name = State()
|
||||||
|
waiting_for_subscription_url = State()
|
||||||
|
waiting_for_days_input = State()
|
||||||
|
waiting_for_new_cluster_name = State()
|
||||||
|
waiting_for_new_server_name = State()
|
||||||
|
waiting_for_server_transfer = State()
|
||||||
|
waiting_for_cluster_transfer = State()
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F, types
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_servers, update_key_expiry
|
||||||
|
from database.models import Key, Server, Tariff
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.keys.operations import renew_key_in_cluster
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .base import AdminClusterStates, router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminClusterCallback,
|
||||||
|
AdminServerCallback,
|
||||||
|
build_cluster_management_kb,
|
||||||
|
build_manage_cluster_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||||
|
async def handle_clusters_manage(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server.tariff_group)
|
||||||
|
.where(
|
||||||
|
Server.cluster_name == cluster_name,
|
||||||
|
Server.tariff_group.isnot(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
tariff_group = row[0] if row else "—"
|
||||||
|
|
||||||
|
result = await session.execute(select(Server.server_name).where(Server.cluster_name == cluster_name))
|
||||||
|
server_names = [row[0] for row in result.all()]
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(func.distinct(Key.tg_id))).where(
|
||||||
|
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_count = result.scalar() or 0
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
|
||||||
|
)
|
||||||
|
subscription_count = result.scalar() or 0
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
|
||||||
|
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
|
||||||
|
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
|
||||||
|
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_cluster_management_kb(cluster_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||||
|
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
cluster_name = callback.data.split("|", 1)[1]
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
from handlers.utils import ALLOWED_GROUP_CODES
|
||||||
|
|
||||||
|
allowed = set(ALLOWED_GROUP_CODES)
|
||||||
|
lines = []
|
||||||
|
for s in cluster_servers:
|
||||||
|
subs = s.get("tariff_subgroups") or []
|
||||||
|
subs_str = ", ".join(sorted(subs)) if subs else "—"
|
||||||
|
|
||||||
|
grps = s.get("special_groups") or []
|
||||||
|
grps = [g for g in grps if g in allowed]
|
||||||
|
grps_str = ", ".join(sorted(grps)) if grps else "—"
|
||||||
|
|
||||||
|
lines.append(f"• {s.get('server_name', '?')} — {subs_str} | {grps_str}")
|
||||||
|
|
||||||
|
details = "\n".join(lines) if lines else "нет серверов"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=(
|
||||||
|
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n"
|
||||||
|
f"<blockquote>{details}</blockquote>"
|
||||||
|
),
|
||||||
|
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||||
|
async def handle_add_time(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_days_input)
|
||||||
|
await state.update_data(cluster_name=cluster_name)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
|
||||||
|
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
days = int(message.text.strip())
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
add_ms = days * 86400 * 1000
|
||||||
|
|
||||||
|
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
|
||||||
|
|
||||||
|
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
|
||||||
|
server_rows = await session.execute(server_stmt)
|
||||||
|
server_names = [row[0] for row in server_rows.all()]
|
||||||
|
server_names.append(cluster_name)
|
||||||
|
|
||||||
|
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
|
||||||
|
keys = result.scalars().all()
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
await message.answer("❌ Нет подписок в этом кластере или сервере.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
new_expiry = key.expiry_time + add_ms
|
||||||
|
|
||||||
|
traffic_limit = 0
|
||||||
|
device_limit = 0
|
||||||
|
key_subgroup = None
|
||||||
|
if key.tariff_id:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
|
||||||
|
Tariff.id == key.tariff_id,
|
||||||
|
Tariff.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tariff = result.first()
|
||||||
|
if tariff:
|
||||||
|
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
|
||||||
|
device_limit = int(tariff[1]) if tariff[1] is not None else 0
|
||||||
|
key_subgroup = tariff[2]
|
||||||
|
|
||||||
|
await renew_key_in_cluster(
|
||||||
|
cluster_name,
|
||||||
|
email=key.email,
|
||||||
|
client_id=key.client_id,
|
||||||
|
new_expiry_time=new_expiry,
|
||||||
|
total_gb=traffic_limit,
|
||||||
|
session=session,
|
||||||
|
hwid_device_limit=device_limit,
|
||||||
|
reset_traffic=False,
|
||||||
|
target_subgroup=key_subgroup,
|
||||||
|
old_subgroup=key_subgroup,
|
||||||
|
)
|
||||||
|
await update_key_expiry(session, key.client_id, new_expiry)
|
||||||
|
|
||||||
|
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректное число дней.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
|
||||||
|
await message.answer("❌ Произошла ошибка при продлении времени.")
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||||
|
async def handle_rename_cluster(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
await state.update_data(old_cluster_name=cluster_name)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
|
||||||
|
"▸ Имя должно быть уникальным.\n"
|
||||||
|
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||||
|
"📌 <i>Пример:</i> <code>new_cluster</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
|
||||||
|
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_cluster_name = message.text.strip()
|
||||||
|
if len(new_cluster_name) > 12:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
old_cluster_name = user_data.get("old_cluster_name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
|
||||||
|
)
|
||||||
|
existing_cluster = result.scalar()
|
||||||
|
|
||||||
|
if existing_cluster:
|
||||||
|
await message.answer(
|
||||||
|
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
keys_count_result = await session.execute(
|
||||||
|
select(func.count()).select_from(Key).where(Key.server_id == old_cluster_name)
|
||||||
|
)
|
||||||
|
keys_count = keys_count_result.scalar()
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Server).where(Server.cluster_name == old_cluster_name).values(cluster_name=new_cluster_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if keys_count > 0:
|
||||||
|
await session.execute(
|
||||||
|
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
|
||||||
|
await message.answer(
|
||||||
|
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||||
|
async def handle_rename_server(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminServerCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
old_server_name = callback_data.data
|
||||||
|
|
||||||
|
servers = await get_servers(session=session)
|
||||||
|
cluster_name = None
|
||||||
|
for c_name, server_list in servers.items():
|
||||||
|
for server in server_list:
|
||||||
|
if server["server_name"] == old_server_name:
|
||||||
|
cluster_name = c_name
|
||||||
|
break
|
||||||
|
if cluster_name:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cluster_name:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
|
||||||
|
"▸ Имя должно быть уникальным в пределах кластера.\n"
|
||||||
|
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||||
|
"📌 <i>Пример:</i> <code>new_server</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
|
||||||
|
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_server_name = message.text.strip()
|
||||||
|
if len(new_server_name) > 12:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
old_server_name = user_data.get("old_server_name")
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server)
|
||||||
|
.where(
|
||||||
|
Server.cluster_name == cluster_name,
|
||||||
|
Server.server_name == new_server_name,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
existing_server = result.scalar()
|
||||||
|
if existing_server:
|
||||||
|
await message.answer(
|
||||||
|
text=(
|
||||||
|
f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. "
|
||||||
|
f"Введите другое имя."
|
||||||
|
),
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
|
||||||
|
keys_count = result.scalar()
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Server)
|
||||||
|
.where(
|
||||||
|
Server.cluster_name == cluster_name,
|
||||||
|
Server.server_name == old_server_name,
|
||||||
|
)
|
||||||
|
.values(server_name=new_server_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if keys_count > 0:
|
||||||
|
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=(
|
||||||
|
f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' "
|
||||||
|
f"в кластере '{cluster_name}'!"
|
||||||
|
),
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
|
||||||
|
await message.answer(
|
||||||
|
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, types
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from py3xui import AsyncApi
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
ADMIN_USERNAME,
|
||||||
|
HAPP_CRYPTOLINK,
|
||||||
|
REMNAWAVE_LOGIN,
|
||||||
|
REMNAWAVE_PASSWORD,
|
||||||
|
USE_COUNTRY_SELECTION,
|
||||||
|
)
|
||||||
|
from database import get_servers
|
||||||
|
from database.models import Key, Server, Tariff
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.keys.operations import (
|
||||||
|
create_client_on_server,
|
||||||
|
create_key_on_cluster,
|
||||||
|
delete_key_from_cluster,
|
||||||
|
)
|
||||||
|
from handlers.keys.operations.aggregated_links import make_aggregated_link
|
||||||
|
from handlers.utils import ALLOWED_GROUP_CODES
|
||||||
|
from logger import logger
|
||||||
|
from panels.remnawave import RemnawaveAPI
|
||||||
|
from utils.backup import create_backup_and_send_to_admins
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .base import router
|
||||||
|
from .keyboard import AdminClusterCallback, build_availability_kb, build_sync_cluster_kb
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
|
||||||
|
async def handle_cluster_availability(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: Any,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
if not cluster_servers:
|
||||||
|
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=(
|
||||||
|
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||||
|
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_online_users = 0
|
||||||
|
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
|
||||||
|
|
||||||
|
for server in cluster_servers:
|
||||||
|
server_name = server["server_name"]
|
||||||
|
panel_type = server.get("panel_type", "3x-ui").lower()
|
||||||
|
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if panel_type == "3x-ui":
|
||||||
|
xui = AsyncApi(
|
||||||
|
server["api_url"],
|
||||||
|
username=ADMIN_USERNAME,
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
logger=None,
|
||||||
|
)
|
||||||
|
await xui.login()
|
||||||
|
inbound_id = int(server["inbound_id"])
|
||||||
|
online_clients = await xui.client.online()
|
||||||
|
online_inbound_users = 0
|
||||||
|
|
||||||
|
for client_email in online_clients:
|
||||||
|
client = await xui.client.get_by_email(client_email)
|
||||||
|
if client and client.inbound_id == inbound_id:
|
||||||
|
online_inbound_users += 1
|
||||||
|
|
||||||
|
total_online_users += online_inbound_users
|
||||||
|
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
|
||||||
|
|
||||||
|
elif panel_type == "remnawave":
|
||||||
|
server_inbound_id = server.get("inbound_id")
|
||||||
|
if not server_inbound_id:
|
||||||
|
raise Exception("Не указан inbound_id сервера")
|
||||||
|
|
||||||
|
remna = RemnawaveAPI(server["api_url"])
|
||||||
|
nodes_data = await remna.get_all_nodes_with_online(
|
||||||
|
username=REMNAWAVE_LOGIN,
|
||||||
|
password=REMNAWAVE_PASSWORD,
|
||||||
|
inbound_id=server_inbound_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if nodes_data.get("error"):
|
||||||
|
raise Exception(nodes_data["error"])
|
||||||
|
|
||||||
|
online_remna_users = nodes_data["total_online"]
|
||||||
|
total_online_users += online_remna_users
|
||||||
|
|
||||||
|
nodes_info = nodes_data["nodes"]
|
||||||
|
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||||
|
seen = set()
|
||||||
|
for node_info in nodes_info:
|
||||||
|
node_name = node_info.get("name", "Unknown")
|
||||||
|
if node_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(node_name)
|
||||||
|
|
||||||
|
country_code = node_info.get("country_code", "Unknown")
|
||||||
|
online_users = node_info.get("online_users", 0)
|
||||||
|
|
||||||
|
flag = (
|
||||||
|
"".join(chr(ord(c) + 127397) for c in country_code.upper())
|
||||||
|
if country_code != "Unknown" and len(country_code) == 2
|
||||||
|
else country_code
|
||||||
|
)
|
||||||
|
result_text += f" ↳ {flag} ({node_name}): {online_users} онлайн\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_text = str(e) or "Сервер недоступен"
|
||||||
|
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
|
||||||
|
|
||||||
|
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=result_text,
|
||||||
|
reply_markup=build_availability_kb(cluster_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
|
||||||
|
async def handle_clusters_backup(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: Any,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
for server in cluster_servers:
|
||||||
|
if server.get("panel_type") == "remnawave":
|
||||||
|
continue
|
||||||
|
|
||||||
|
xui = AsyncApi(
|
||||||
|
server["api_url"],
|
||||||
|
username=ADMIN_USERNAME,
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
await create_backup_and_send_to_admins(xui)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||||
|
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
|
||||||
|
async def handle_sync(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: Any,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
|
||||||
|
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
|
||||||
|
async def handle_sync_server(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
server_name = callback_data.data
|
||||||
|
|
||||||
|
try:
|
||||||
|
server_result = await session.execute(
|
||||||
|
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
|
||||||
|
)
|
||||||
|
cluster_name = server_result.scalar()
|
||||||
|
|
||||||
|
if not cluster_name:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Сервер {server_name} не найден.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if USE_COUNTRY_SELECTION:
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Server.api_url,
|
||||||
|
Server.inbound_id,
|
||||||
|
Server.server_name,
|
||||||
|
Server.panel_type,
|
||||||
|
Key.tg_id,
|
||||||
|
Key.client_id,
|
||||||
|
Key.email,
|
||||||
|
Key.expiry_time,
|
||||||
|
Key.tariff_id,
|
||||||
|
Key.remnawave_link,
|
||||||
|
)
|
||||||
|
.join(Key, Server.server_name == Key.server_id)
|
||||||
|
.where(Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Server.api_url,
|
||||||
|
Server.inbound_id,
|
||||||
|
Server.server_name,
|
||||||
|
Server.panel_type,
|
||||||
|
Key.tg_id,
|
||||||
|
Key.client_id,
|
||||||
|
Key.email,
|
||||||
|
Key.expiry_time,
|
||||||
|
Key.tariff_id,
|
||||||
|
Key.remnawave_link,
|
||||||
|
)
|
||||||
|
.join(Key, Server.cluster_name == Key.server_id)
|
||||||
|
.where(Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
keys_to_sync = result.mappings().all()
|
||||||
|
|
||||||
|
if not keys_to_sync:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(2)
|
||||||
|
for key in keys_to_sync:
|
||||||
|
try:
|
||||||
|
if key["panel_type"] == "remnawave":
|
||||||
|
tariff = None
|
||||||
|
if key["tariff_id"]:
|
||||||
|
tariff = await session.get(Tariff, key["tariff_id"])
|
||||||
|
if tariff:
|
||||||
|
servers = await get_servers(session)
|
||||||
|
server_info = None
|
||||||
|
for cluster_servers in servers.values():
|
||||||
|
for s in cluster_servers:
|
||||||
|
if s.get("server_name") == server_name:
|
||||||
|
server_info = s
|
||||||
|
break
|
||||||
|
if server_info:
|
||||||
|
break
|
||||||
|
|
||||||
|
if server_info:
|
||||||
|
if tariff.subgroup_title and tariff.subgroup_title not in server_info.get(
|
||||||
|
"tariff_subgroups", []
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tariff.group_code and tariff.group_code.lower() in ALLOWED_GROUP_CODES:
|
||||||
|
if tariff.group_code.lower() not in server_info.get("special_groups", []):
|
||||||
|
continue
|
||||||
|
|
||||||
|
expire_iso = (
|
||||||
|
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
remna = RemnawaveAPI(key["api_url"])
|
||||||
|
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
traffic_limit_bytes = 0
|
||||||
|
hwid_limit = 0
|
||||||
|
if tariff:
|
||||||
|
if tariff.traffic_limit is not None:
|
||||||
|
traffic_limit_bytes = int(tariff.traffic_limit * 1024**3)
|
||||||
|
hwid_limit = tariff.device_limit
|
||||||
|
|
||||||
|
success = await remna.update_user(
|
||||||
|
uuid=key["client_id"],
|
||||||
|
expire_at=expire_iso,
|
||||||
|
telegram_id=key["tg_id"],
|
||||||
|
email=f"{key['email']}@fake.local",
|
||||||
|
active_user_inbounds=[key["inbound_id"]],
|
||||||
|
traffic_limit_bytes=traffic_limit_bytes,
|
||||||
|
hwid_device_limit=hwid_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
sub = await remna.get_subscription_by_username(key["email"])
|
||||||
|
if sub:
|
||||||
|
new_remnawave_link = sub.get("subscriptionUrl")
|
||||||
|
if HAPP_CRYPTOLINK:
|
||||||
|
happ = sub.get("happ") or {}
|
||||||
|
new_remnawave_link = (
|
||||||
|
happ.get("cryptoLink") or happ.get("link") or new_remnawave_link
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_remnawave_link:
|
||||||
|
server_result = await session.execute(
|
||||||
|
select(Server.cluster_name).where(Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
cluster_name = server_result.scalar()
|
||||||
|
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
key_value = await make_aggregated_link(
|
||||||
|
session=session,
|
||||||
|
cluster_all=cluster_servers,
|
||||||
|
cluster_id=cluster_name,
|
||||||
|
email=key["email"],
|
||||||
|
client_id=key["client_id"],
|
||||||
|
tg_id=key["tg_id"],
|
||||||
|
remna_link_override=new_remnawave_link,
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||||
|
.values(remnawave_link=new_remnawave_link, key=key_value)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
|
||||||
|
|
||||||
|
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
|
||||||
|
|
||||||
|
await create_key_on_cluster(
|
||||||
|
cluster_id=server_name,
|
||||||
|
tg_id=key["tg_id"],
|
||||||
|
client_id=key["client_id"],
|
||||||
|
email=key["email"],
|
||||||
|
expiry_timestamp=key["expiry_time"],
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
session=session,
|
||||||
|
remnawave_link=key["remnawave_link"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await create_client_on_server(
|
||||||
|
{
|
||||||
|
"api_url": key["api_url"],
|
||||||
|
"inbound_id": key["inbound_id"],
|
||||||
|
"server_name": key["server_name"],
|
||||||
|
},
|
||||||
|
key["tg_id"],
|
||||||
|
key["client_id"],
|
||||||
|
key["email"],
|
||||||
|
key["expiry_time"],
|
||||||
|
semaphore,
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.6)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
|
||||||
|
async def handle_sync_cluster(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
try:
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
if USE_COUNTRY_SELECTION:
|
||||||
|
server_names = [s.get("server_name") for s in cluster_servers if s.get("server_name")]
|
||||||
|
if not server_names:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ В кластере {cluster_name} нет серверов.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Key.tg_id,
|
||||||
|
Key.client_id,
|
||||||
|
Key.email,
|
||||||
|
Key.expiry_time,
|
||||||
|
Key.remnawave_link,
|
||||||
|
Key.tariff_id,
|
||||||
|
Key.server_id,
|
||||||
|
).where(Key.server_id.in_(server_names), Key.is_frozen.is_(False))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Key.tg_id,
|
||||||
|
Key.client_id,
|
||||||
|
Key.email,
|
||||||
|
Key.expiry_time,
|
||||||
|
Key.remnawave_link,
|
||||||
|
Key.tariff_id,
|
||||||
|
Key.server_id,
|
||||||
|
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
|
||||||
|
)
|
||||||
|
|
||||||
|
keys_to_sync = result.mappings().all()
|
||||||
|
|
||||||
|
if not keys_to_sync:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
only_remnawave = all(s.get("panel_type") == "remnawave" for s in cluster_servers)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in keys_to_sync:
|
||||||
|
try:
|
||||||
|
if only_remnawave:
|
||||||
|
expire_iso = (
|
||||||
|
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
traffic_limit_bytes = 0
|
||||||
|
hwid_limit = 0
|
||||||
|
subgroup_title = None
|
||||||
|
tariff = None
|
||||||
|
if key["tariff_id"]:
|
||||||
|
tariff = await session.get(Tariff, key["tariff_id"])
|
||||||
|
if tariff:
|
||||||
|
if tariff.traffic_limit is not None:
|
||||||
|
traffic_limit_bytes = int(tariff.traffic_limit * 1024**3)
|
||||||
|
else:
|
||||||
|
traffic_limit_bytes = 0
|
||||||
|
hwid_limit = tariff.device_limit
|
||||||
|
subgroup_title = tariff.subgroup_title
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']} — "
|
||||||
|
f"обновим без лимитов"
|
||||||
|
)
|
||||||
|
|
||||||
|
if USE_COUNTRY_SELECTION:
|
||||||
|
user_server = None
|
||||||
|
for s in cluster_servers:
|
||||||
|
if s.get("server_name") == key["server_id"]:
|
||||||
|
user_server = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_server:
|
||||||
|
logger.warning(
|
||||||
|
f"[Sync] Сервер {key['server_id']} не найден в кластере {cluster_name}, пропускаем ключ"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remna = RemnawaveAPI(user_server["api_url"])
|
||||||
|
inbound_ids = [user_server["inbound_id"]] if user_server.get("inbound_id") else []
|
||||||
|
else:
|
||||||
|
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
|
||||||
|
|
||||||
|
filtered_servers = cluster_servers
|
||||||
|
if subgroup_title:
|
||||||
|
filtered_servers = [
|
||||||
|
s for s in cluster_servers if subgroup_title in s.get("tariff_subgroups", [])
|
||||||
|
]
|
||||||
|
if not filtered_servers:
|
||||||
|
logger.warning(
|
||||||
|
f"[Sync] В кластере {cluster_name} не найдено серверов для подгруппы "
|
||||||
|
f"'{subgroup_title}'. Использую весь кластер."
|
||||||
|
)
|
||||||
|
filtered_servers = cluster_servers
|
||||||
|
|
||||||
|
if tariff and tariff.group_code:
|
||||||
|
group_code = tariff.group_code.lower()
|
||||||
|
if group_code in ALLOWED_GROUP_CODES:
|
||||||
|
special_filtered = [
|
||||||
|
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
|
||||||
|
]
|
||||||
|
if special_filtered:
|
||||||
|
filtered_servers = special_filtered
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Sync] В кластере {cluster_name} нет серверов со спецгруппой "
|
||||||
|
f"'{group_code}'. Использую весь кластер."
|
||||||
|
)
|
||||||
|
|
||||||
|
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
|
||||||
|
|
||||||
|
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
raise Exception("Не удалось авторизоваться в Remnawave")
|
||||||
|
|
||||||
|
success = await remna.update_user(
|
||||||
|
uuid=key["client_id"],
|
||||||
|
expire_at=expire_iso,
|
||||||
|
telegram_id=key["tg_id"],
|
||||||
|
email=f"{key['email']}@fake.local",
|
||||||
|
active_user_inbounds=inbound_ids,
|
||||||
|
traffic_limit_bytes=traffic_limit_bytes,
|
||||||
|
hwid_device_limit=hwid_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
sub = await remna.get_subscription_by_username(key["email"])
|
||||||
|
if sub:
|
||||||
|
new_remnawave_link = sub.get("subscriptionUrl")
|
||||||
|
if HAPP_CRYPTOLINK:
|
||||||
|
happ = sub.get("happ") or {}
|
||||||
|
new_remnawave_link = (
|
||||||
|
happ.get("cryptoLink") or happ.get("link") or new_remnawave_link
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_remnawave_link:
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
key_value = await make_aggregated_link(
|
||||||
|
session=session,
|
||||||
|
cluster_all=cluster_servers,
|
||||||
|
cluster_id=cluster_name,
|
||||||
|
email=key["email"],
|
||||||
|
client_id=key["client_id"],
|
||||||
|
tg_id=key["tg_id"],
|
||||||
|
remna_link_override=new_remnawave_link,
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||||
|
.values(remnawave_link=new_remnawave_link, key=key_value)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
|
||||||
|
|
||||||
|
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster_id_for_recreate = key["server_id"] if USE_COUNTRY_SELECTION else cluster_name
|
||||||
|
await create_key_on_cluster(
|
||||||
|
cluster_id_for_recreate,
|
||||||
|
key["tg_id"],
|
||||||
|
key["client_id"],
|
||||||
|
key["email"],
|
||||||
|
key["expiry_time"],
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
session=session,
|
||||||
|
remnawave_link=key["remnawave_link"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster_id_for_recreate = key["server_id"] if USE_COUNTRY_SELECTION else cluster_name
|
||||||
|
await create_key_on_cluster(
|
||||||
|
cluster_id_for_recreate,
|
||||||
|
key["tg_id"],
|
||||||
|
key["client_id"],
|
||||||
|
key["email"],
|
||||||
|
key["expiry_time"],
|
||||||
|
plan=key["tariff_id"],
|
||||||
|
session=session,
|
||||||
|
remnawave_link=key["remnawave_link"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Sync] Ошибка синхронизации кластера {cluster_name}: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import and_, delete, func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_servers
|
||||||
|
from database.models import Server, ServerSpecialgroup, ServerSubgroup, Tariff
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.utils import ALLOWED_GROUP_CODES
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .base import router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminClusterCallback,
|
||||||
|
build_attach_tariff_kb,
|
||||||
|
build_manage_cluster_kb,
|
||||||
|
build_select_group_servers_kb,
|
||||||
|
build_select_subgroup_servers_kb,
|
||||||
|
build_tariff_group_selection_for_servers_kb,
|
||||||
|
build_tariff_group_selection_kb,
|
||||||
|
build_tariff_subgroup_selection_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
|
||||||
|
async def show_tariff_group_selection(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.id, Tariff.group_code).where(Tariff.group_code.isnot(None)).distinct(Tariff.group_code)
|
||||||
|
)
|
||||||
|
rows = result.mappings().all()
|
||||||
|
groups = [(r["id"], r["group_code"]) for r in rows]
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
await callback.message.edit_text("❌ Нет доступных тарифных групп.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>💸 Выберите тарифную группу для кластера <code>{cluster_name}</code>:</b>",
|
||||||
|
reply_markup=build_tariff_group_selection_kb(cluster_name, groups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
|
||||||
|
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
cluster_name, group_id = callback_data.data.split("|", 1)
|
||||||
|
group_id = int(group_id)
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff.group_code).where(Tariff.id == group_id))
|
||||||
|
row = result.mappings().first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
await callback.message.edit_text("❌ Тарифная группа не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = row["group_code"]
|
||||||
|
|
||||||
|
await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
|
||||||
|
reply_markup=build_manage_cluster_kb(cluster_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при применении тарифной группы: {e}")
|
||||||
|
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "set_subgroup"))
|
||||||
|
async def show_servers_for_subgroup(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
|
||||||
|
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_subgroup"))
|
||||||
|
async def toggle_server_for_subgroup(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||||
|
i = int(idx_str)
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
names = []
|
||||||
|
for s in cluster_servers:
|
||||||
|
if isinstance(s, str):
|
||||||
|
names.append(s)
|
||||||
|
elif isinstance(s, dict):
|
||||||
|
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||||
|
else:
|
||||||
|
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||||
|
if i < 0 or i >= len(names):
|
||||||
|
await callback.answer("Сервер не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
server_name = names[i]
|
||||||
|
key = f"subgrp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if server_name in selected:
|
||||||
|
selected.remove(server_name)
|
||||||
|
else:
|
||||||
|
selected.add(server_name)
|
||||||
|
await state.update_data({key: list(selected)})
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
|
||||||
|
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_subgroup_selection"))
|
||||||
|
async def reset_subgroup_selection(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
await state.update_data({f"subgrp_sel:{cluster_name}": []})
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
|
||||||
|
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_subgroup"))
|
||||||
|
async def choose_subgroup(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
key = f"subgrp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if not selected:
|
||||||
|
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
|
||||||
|
group_codes = [r[0] for r in res.fetchall() if r[0]]
|
||||||
|
if not group_codes:
|
||||||
|
await callback.answer("Сначала установите тарифную группу для этого кластера", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = group_codes[0]
|
||||||
|
|
||||||
|
res2 = await session.execute(
|
||||||
|
select(func.distinct(Tariff.subgroup_title))
|
||||||
|
.where(Tariff.group_code == group_code)
|
||||||
|
.where(Tariff.subgroup_title.isnot(None))
|
||||||
|
.order_by(Tariff.subgroup_title.asc())
|
||||||
|
)
|
||||||
|
subgroups = [r[0] for r in res2.fetchall()]
|
||||||
|
if not subgroups:
|
||||||
|
await callback.message.edit_text("❌ Для этой группы нет доступных подгрупп.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>📚 Выберите подгруппу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
|
||||||
|
reply_markup=build_tariff_subgroup_selection_kb(cluster_name, subgroups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_subgroup"))
|
||||||
|
async def apply_tariff_subgroup(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||||
|
i = int(idx_str)
|
||||||
|
|
||||||
|
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
|
||||||
|
group_codes = [r[0] for r in res.fetchall() if r[0]]
|
||||||
|
if not group_codes:
|
||||||
|
await callback.answer("Не найдена тарифная группа кластера", show_alert=True)
|
||||||
|
return
|
||||||
|
group_code = group_codes[0]
|
||||||
|
|
||||||
|
res2 = await session.execute(
|
||||||
|
select(func.distinct(Tariff.subgroup_title))
|
||||||
|
.where(Tariff.group_code == group_code)
|
||||||
|
.where(Tariff.subgroup_title.isnot(None))
|
||||||
|
.order_by(Tariff.subgroup_title.asc())
|
||||||
|
)
|
||||||
|
subgroups = [r[0] for r in res2.fetchall()]
|
||||||
|
if i < 0 or i >= len(subgroups):
|
||||||
|
await callback.answer("Подгруппа не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
subgroup_title = subgroups[i]
|
||||||
|
|
||||||
|
key = f"subgrp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if not selected:
|
||||||
|
await callback.message.edit_text("❌ Не выбраны серверы для назначения подгруппы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
servers_q = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
|
||||||
|
id_by_name = {name: sid for sid, name in servers_q.fetchall()}
|
||||||
|
missing_ids = [id_by_name[n] for n in selected if n in id_by_name]
|
||||||
|
if not missing_ids:
|
||||||
|
await callback.answer("Серверы не найдены", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_q = await session.execute(
|
||||||
|
select(ServerSubgroup.server_id)
|
||||||
|
.where(ServerSubgroup.server_id.in_(missing_ids))
|
||||||
|
.where(ServerSubgroup.subgroup_title == subgroup_title)
|
||||||
|
)
|
||||||
|
already = {r[0] for r in existing_q.fetchall()}
|
||||||
|
to_insert = [sid for sid in missing_ids if sid not in already]
|
||||||
|
|
||||||
|
if to_insert:
|
||||||
|
session.add_all([
|
||||||
|
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=subgroup_title) for sid in to_insert
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await state.update_data({key: []})
|
||||||
|
|
||||||
|
servers = await get_servers(session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при применении подгруппы тарифов: {e}")
|
||||||
|
await callback.message.edit_text("❌ Произошла ошибка при назначении подгруппы.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_subgroups"))
|
||||||
|
async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
|
||||||
|
server_ids = [row[0] for row in res.fetchall()]
|
||||||
|
if not server_ids:
|
||||||
|
await callback.answer("В кластере нет серверов", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await session.execute(delete(ServerSubgroup).where(ServerSubgroup.server_id.in_(server_ids)))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Все подгруппы тарифов сброшены для кластера <b>{cluster_name}</b>.",
|
||||||
|
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
|
||||||
|
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
|
||||||
|
|
||||||
|
|
||||||
|
def render_attach_tariff_menu_text(cluster_name: str, cluster_servers: list[dict]) -> str:
|
||||||
|
sub_map: dict[str, list[str]] = {}
|
||||||
|
for s in cluster_servers:
|
||||||
|
for sg in s.get("tariff_subgroups") or []:
|
||||||
|
sub_map.setdefault(sg, []).append(s["server_name"])
|
||||||
|
|
||||||
|
allowed = tuple(ALLOWED_GROUP_CODES)
|
||||||
|
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
|
||||||
|
for s in cluster_servers:
|
||||||
|
for g in s.get("special_groups") or []:
|
||||||
|
if g in spec_map:
|
||||||
|
spec_map[g].append(s["server_name"])
|
||||||
|
|
||||||
|
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
|
||||||
|
|
||||||
|
lines.append("<b>Подгруппы:</b>")
|
||||||
|
if sub_map:
|
||||||
|
subs_lines = []
|
||||||
|
for k in sorted(sub_map):
|
||||||
|
servers_list = ", ".join(sorted(set(sub_map[k])))
|
||||||
|
subs_lines.append(f"• <b>{k}</b>: {servers_list}")
|
||||||
|
lines.append("<blockquote>\n" + "\n".join(subs_lines) + "\n</blockquote>")
|
||||||
|
else:
|
||||||
|
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||||
|
|
||||||
|
lines.append("<b>Спецгруппы:</b>")
|
||||||
|
has_spec = any(spec_map[k] for k in allowed)
|
||||||
|
if has_spec:
|
||||||
|
spec_lines = []
|
||||||
|
for k in allowed:
|
||||||
|
vals = sorted(set(spec_map[k]))
|
||||||
|
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else '—'}")
|
||||||
|
lines.append("<blockquote>\n" + "\n".join(spec_lines) + "\n</blockquote>")
|
||||||
|
else:
|
||||||
|
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
|
||||||
|
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
packed = AdminClusterCallback.unpack(callback.data)
|
||||||
|
cluster_name = packed.data
|
||||||
|
|
||||||
|
servers = await get_servers(session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
|
||||||
|
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"))
|
||||||
|
async def show_servers_for_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(f"grp_sel:{cluster_name}", []))
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||||
|
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"))
|
||||||
|
async def toggle_server_for_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||||
|
i = int(idx_str)
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
names = []
|
||||||
|
for s in cluster_servers:
|
||||||
|
if isinstance(s, str):
|
||||||
|
names.append(s)
|
||||||
|
elif isinstance(s, dict):
|
||||||
|
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||||
|
else:
|
||||||
|
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||||
|
if i < 0 or i >= len(names):
|
||||||
|
await callback.answer("Сервер не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
server_name = names[i]
|
||||||
|
key = f"grp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if server_name in selected:
|
||||||
|
selected.remove(server_name)
|
||||||
|
else:
|
||||||
|
selected.add(server_name)
|
||||||
|
await state.update_data({key: list(selected)})
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||||
|
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"))
|
||||||
|
async def reset_group_selection(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
await state.update_data({f"grp_sel:{cluster_name}": []})
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||||
|
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"))
|
||||||
|
async def choose_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
key = f"grp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if not selected:
|
||||||
|
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
|
||||||
|
return
|
||||||
|
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
|
||||||
|
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"))
|
||||||
|
async def apply_group_to_servers(
|
||||||
|
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||||
|
i = int(idx_str)
|
||||||
|
groups = ALLOWED_GROUP_CODES
|
||||||
|
if i < 0 or i >= len(groups):
|
||||||
|
await callback.answer("Группа не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
group_code = groups[i]
|
||||||
|
|
||||||
|
key = f"grp_sel:{cluster_name}"
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get(key, []))
|
||||||
|
if not selected:
|
||||||
|
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
|
||||||
|
id_by_name = {name: sid for sid, name in rows.fetchall()}
|
||||||
|
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
|
||||||
|
if not server_ids:
|
||||||
|
await callback.answer("Серверы не найдены", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
exist_rows = await session.execute(
|
||||||
|
select(ServerSpecialgroup.server_id).where(
|
||||||
|
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
already = {r[0] for r in exist_rows.fetchall()}
|
||||||
|
to_insert = [sid for sid in server_ids if sid not in already]
|
||||||
|
|
||||||
|
if to_insert:
|
||||||
|
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.debug(f"[apply_group_to_servers] group={group_code} server_ids={server_ids}")
|
||||||
|
|
||||||
|
await state.update_data({key: []})
|
||||||
|
|
||||||
|
servers = await get_servers(session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при назначении группы тарифов: {e}")
|
||||||
|
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"))
|
||||||
|
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
|
||||||
|
server_ids = [row[0] for row in res.fetchall()]
|
||||||
|
if not server_ids:
|
||||||
|
await callback.answer("В кластере нет серверов", show_alert=True)
|
||||||
|
return
|
||||||
|
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
|
||||||
|
await session.commit()
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_servers = servers.get(cluster_name, [])
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
|
||||||
|
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
|
||||||
|
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import delete, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import USE_COUNTRY_SELECTION
|
||||||
|
from database.models import Key, Server
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .base import router
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("transfer_to_server|"))
|
||||||
|
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
data = callback_query.data.split("|")
|
||||||
|
new_server_name = data[1]
|
||||||
|
old_server_name = data[2]
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
|
||||||
|
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(Server).where(
|
||||||
|
Server.cluster_name == cluster_name,
|
||||||
|
Server.server_name == old_server_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
|
||||||
|
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
|
||||||
|
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=final_text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
|
||||||
|
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
data = callback_query.data.split("|")
|
||||||
|
new_cluster_name = data[1]
|
||||||
|
old_cluster_name = data[2]
|
||||||
|
old_server_name = data[3]
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
|
||||||
|
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_cluster_name))
|
||||||
|
await session.execute(update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name))
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(Server).where(
|
||||||
|
Server.cluster_name == cluster_name,
|
||||||
|
Server.server_name == old_server_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=(
|
||||||
|
f"✅ Ключи успешно перенесены в кластер '<b>{new_cluster_name}</b>', "
|
||||||
|
f"сервер '<b>{old_server_name}</b>' и кластер '<b>{old_cluster_name}</b>' удалены!\n\n"
|
||||||
|
f'⚠️ Не забудьте сделать "Синхронизацию".'
|
||||||
|
),
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import check_unique_server_name, get_servers
|
||||||
|
from database.models import Server
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
from .base import AdminClusterStates, router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminClusterCallback,
|
||||||
|
AdminServerCallback,
|
||||||
|
build_clusters_editor_kb,
|
||||||
|
build_panel_type_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "clusters"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_servers(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
servers = await get_servers(session, include_enabled=True)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"<b>🔧 Управление кластерами</b>\n\n"
|
||||||
|
"<blockquote>"
|
||||||
|
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
|
||||||
|
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
|
||||||
|
"</blockquote>\n\n"
|
||||||
|
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_clusters_editor_kb(servers),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
|
||||||
|
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
text = (
|
||||||
|
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||||
|
"<b>Имя должно быть уникальным!</b>\n"
|
||||||
|
"<b>Имя не должно превышать 12 символов!</b>\n\n"
|
||||||
|
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||||
|
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
|
||||||
|
async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(message.text) > 12:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cluster_name = message.text.strip()
|
||||||
|
await state.update_data(cluster_name=cluster_name)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||||
|
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||||
|
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
|
||||||
|
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
server_name = message.text.strip()
|
||||||
|
|
||||||
|
if len(server_name) > 12:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
|
||||||
|
if not await check_unique_server_name(session, server_name, cluster_name):
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(server_name=server_name)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||||
|
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
|
||||||
|
"ℹ️ <b>Формат для 3X-UI:</b>\n"
|
||||||
|
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
|
||||||
|
"ℹ️ <b>Формат для Remnawave:</b>\n"
|
||||||
|
"<code>https://your-domain.com/api</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_api_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
|
||||||
|
async def handle_api_url_input(message: Message, state: FSMContext):
|
||||||
|
api_url = message.text.strip().rstrip("/")
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
server_name = user_data.get("server_name")
|
||||||
|
|
||||||
|
await state.update_data(api_url=api_url)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||||
|
"Если вы используете Remnawave — введите <code>0</code>\n\n"
|
||||||
|
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
|
||||||
|
async def handle_subscription_url_input(message: Message, state: FSMContext):
|
||||||
|
raw = message.text.strip()
|
||||||
|
subscription_url = None if raw == "0" else raw.rstrip("/")
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
server_name = user_data.get("server_name")
|
||||||
|
|
||||||
|
await state.update_data(subscription_url=subscription_url)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=f"<b>Введите inbound_id/Squads для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||||
|
f"Для Remnawave это UUID Squads, для 3x-ui — просто ID (например, <code>1</code>).",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
|
||||||
|
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||||
|
inbound_id = message.text.strip()
|
||||||
|
await state.update_data(inbound_id=inbound_id)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=(
|
||||||
|
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
|
||||||
|
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
|
||||||
|
"Поддержка режима выбора стран — <b>ограничена</b>."
|
||||||
|
),
|
||||||
|
reply_markup=build_panel_type_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_panel_type_selection(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminClusterCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
cluster_name = user_data.get("cluster_name")
|
||||||
|
server_name = user_data.get("server_name")
|
||||||
|
api_url = user_data.get("api_url")
|
||||||
|
subscription_url = user_data.get("subscription_url")
|
||||||
|
inbound_id = user_data.get("inbound_id")
|
||||||
|
|
||||||
|
result = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1))
|
||||||
|
row = result.first()
|
||||||
|
tariff_group = row[0] if row else None
|
||||||
|
|
||||||
|
new_server = Server(
|
||||||
|
cluster_name=cluster_name,
|
||||||
|
server_name=server_name,
|
||||||
|
api_url=api_url,
|
||||||
|
subscription_url=subscription_url,
|
||||||
|
inbound_id=inbound_id,
|
||||||
|
panel_type=panel_type,
|
||||||
|
tariff_group=tariff_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(new_server)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
|
||||||
|
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||||
|
cluster_name = callback_data.data
|
||||||
|
|
||||||
|
await state.update_data(cluster_name=cluster_name)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||||
|
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||||
|
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -329,7 +329,18 @@ def build_tariff_group_selection_for_servers_kb(
|
|||||||
|
|
||||||
|
|
||||||
def build_availability_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
def build_availability_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[
|
return InlineKeyboardMarkup(
|
||||||
[InlineKeyboardButton(text="🔁 Обновить", callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack())],
|
inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())],
|
[
|
||||||
])
|
InlineKeyboardButton(
|
||||||
|
text="🔁 Обновить",
|
||||||
|
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database.models import Gift, GiftUsage, Tariff
|
from database.models import Gift, GiftUsage
|
||||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||||
from handlers.utils import edit_or_send_message, format_days, format_months
|
from handlers.utils import edit_or_send_message, format_days, format_months
|
||||||
from logger import logger
|
from logger import logger
|
||||||
@@ -181,44 +181,18 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
|
|||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
gifts = result.scalars().all()
|
gifts = result.scalars().all()
|
||||||
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
|
|
||||||
if not gifts:
|
if not gifts:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||||
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
||||||
return
|
return
|
||||||
|
|
||||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
||||||
|
|
||||||
builder.inline_keyboard.extend(keyboard.inline_keyboard)
|
await callback.message.edit_text(
|
||||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
f"🎁 <b>Список подарков</b>\nСтраница {page}:",
|
||||||
|
reply_markup=keyboard,
|
||||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
)
|
||||||
|
|
||||||
|
|
||||||
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
|
|
||||||
limit = 10
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
|
|
||||||
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
gifts = result.scalars().all()
|
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
|
|
||||||
if not gifts:
|
|
||||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
|
||||||
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
|
||||||
return
|
|
||||||
|
|
||||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
|
||||||
|
|
||||||
for row in keyboard.inline_keyboard:
|
|
||||||
builder.row(*row)
|
|
||||||
|
|
||||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("gift_view|"))
|
@router.callback_query(F.data.startswith("gift_view|"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
|
|
||||||
from database.models import Gift, Tariff
|
from database.models import Gift, Tariff
|
||||||
from handlers.buttons import BACK
|
from handlers.buttons import BACK
|
||||||
from handlers.utils import format_days, format_months
|
from handlers.utils import format_days
|
||||||
|
|
||||||
from ..panel.keyboard import AdminPanelCallback
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
|
||||||
@@ -37,11 +37,8 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
|
|||||||
row = []
|
row = []
|
||||||
|
|
||||||
for i, gift in enumerate(gifts):
|
for i, gift in enumerate(gifts):
|
||||||
if gift.selected_months > 0:
|
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||||
duration_text = format_months(gift.selected_months)
|
duration_text = format_days(days)
|
||||||
else:
|
|
||||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
|
||||||
duration_text = format_days(days)
|
|
||||||
|
|
||||||
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
|
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
__all__ = ("router",)
|
from aiogram import Router
|
||||||
|
|
||||||
from .management_handler import router
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
admins, # noqa: F401
|
||||||
|
database, # noqa: F401
|
||||||
|
domain, # noqa: F401
|
||||||
|
file_upload, # noqa: F401
|
||||||
|
import_3xui, # noqa: F401
|
||||||
|
maintenance, # noqa: F401
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
from asyncio import sleep
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Admin
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminPanelCallback,
|
||||||
|
build_admin_back_kb_to_admins,
|
||||||
|
build_admins_kb,
|
||||||
|
build_role_selection_kb,
|
||||||
|
build_single_admin_menu,
|
||||||
|
build_token_result_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminState(StatesGroup):
|
||||||
|
waiting_for_tg_id = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
|
||||||
|
async def show_admins(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(select(Admin.tg_id, Admin.role))
|
||||||
|
admins = result.all()
|
||||||
|
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
|
||||||
|
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
|
||||||
|
)
|
||||||
|
await state.set_state(AdminState.waiting_for_tg_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminState.waiting_for_tg_id)
|
||||||
|
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
|
||||||
|
try:
|
||||||
|
tg_id = int(message.text.strip())
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
await message.answer("⚠️ Такой админ уже существует.")
|
||||||
|
else:
|
||||||
|
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
|
||||||
|
await session.commit()
|
||||||
|
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
|
||||||
|
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||||
|
tg_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
role = admin or "moderator"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
|
||||||
|
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||||
|
tg_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
if not admin:
|
||||||
|
await callback.message.edit_text("❌ Админ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
token = Admin.generate_token()
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
admin.token = token_hash
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
msg = await callback.message.edit_text(
|
||||||
|
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
|
||||||
|
f"<code>{token}</code>\n\n"
|
||||||
|
f"⚠️ Это сообщение исчезнет через 5 минут.",
|
||||||
|
reply_markup=build_token_result_kb(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
await sleep(300)
|
||||||
|
try:
|
||||||
|
await msg.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
|
||||||
|
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
|
||||||
|
tg_id = int(callback_data.action.split("|")[1])
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
|
||||||
|
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
_, tg_id_str, role = callback_data.action.split("|")
|
||||||
|
tg_id = int(tg_id_str)
|
||||||
|
if role not in ("superadmin", "moderator"):
|
||||||
|
raise ValueError
|
||||||
|
except Exception:
|
||||||
|
await callback.message.edit_text("❌ Неверный формат.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if tg_id == callback.from_user.id:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
if not admin:
|
||||||
|
await callback.message.edit_text("❌ Админ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
admin.role = role
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
|
||||||
|
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||||
|
tg_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
from aiogram import Bot, F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_database_kb, build_export_db_sources_kb
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseState(StatesGroup):
|
||||||
|
waiting_for_backup_file = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
|
||||||
|
async def handle_database_menu(callback: CallbackQuery):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text="🗄 <b>Управление базой данных</b>",
|
||||||
|
reply_markup=build_database_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
|
||||||
|
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
|
||||||
|
"⚠️ Все текущие данные будут перезаписаны.",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
await state.set_state(DatabaseState.waiting_for_backup_file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(DatabaseState.waiting_for_backup_file)
|
||||||
|
async def restore_database(message: Message, state: FSMContext, bot: Bot):
|
||||||
|
document = message.document
|
||||||
|
|
||||||
|
if not document or not document.file_name.endswith(".sql"):
|
||||||
|
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
|
||||||
|
tmp_path = tmp_file.name
|
||||||
|
|
||||||
|
await bot.download(document, destination=tmp_path)
|
||||||
|
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
|
||||||
|
|
||||||
|
is_custom_dump = False
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
signature = f.read(5)
|
||||||
|
if signature == b"PGDMP":
|
||||||
|
is_custom_dump = True
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"sudo",
|
||||||
|
"-u",
|
||||||
|
"postgres",
|
||||||
|
"psql",
|
||||||
|
"-d",
|
||||||
|
"postgres",
|
||||||
|
"-c",
|
||||||
|
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[Restore] База данных пересоздана")
|
||||||
|
|
||||||
|
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||||
|
|
||||||
|
if is_custom_dump:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"pg_restore",
|
||||||
|
f"--dbname={DB_NAME}",
|
||||||
|
"-U",
|
||||||
|
DB_USER,
|
||||||
|
"-h",
|
||||||
|
PG_HOST,
|
||||||
|
"-p",
|
||||||
|
PG_PORT,
|
||||||
|
"--no-owner",
|
||||||
|
"--exit-on-error",
|
||||||
|
tmp_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
DB_USER,
|
||||||
|
"-h",
|
||||||
|
PG_HOST,
|
||||||
|
"-p",
|
||||||
|
PG_PORT,
|
||||||
|
"-d",
|
||||||
|
DB_NAME,
|
||||||
|
"-f",
|
||||||
|
tmp_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
del os.environ["PGPASSWORD"]
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"✅ База данных восстановлена.",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
|
||||||
|
await state.clear()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
|
||||||
|
async def handle_export_db(callback: CallbackQuery):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📤 Выберите панель, с которой требуется получить данные:\n\n"
|
||||||
|
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
|
||||||
|
reply_markup=build_export_db_sources_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
|
||||||
|
async def back_to_database_menu(callback: CallbackQuery):
|
||||||
|
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback
|
||||||
|
|
||||||
|
|
||||||
|
class AdminManagementStates(StatesGroup):
|
||||||
|
waiting_for_new_domain = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||||
|
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||||
|
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
new_domain = message.text.strip()
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
|
||||||
|
logger.warning("[DomainChange] Некорректный домен")
|
||||||
|
await message.answer(
|
||||||
|
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_domain_url = f"https://{new_domain}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
update(Key)
|
||||||
|
.values(
|
||||||
|
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||||
|
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||||
|
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Ошибка при обновлении домена: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
|
||||||
|
example = sample.fetchone()
|
||||||
|
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Домен успешно изменен на {new_domain}!",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadState(StatesGroup):
|
||||||
|
waiting_for_target = State()
|
||||||
|
waiting_for_file = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
|
||||||
|
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
||||||
|
text = (
|
||||||
|
"📤 <b>Загрузка файла</b>\n\n"
|
||||||
|
"Вы можете заменить файл в корневой директории бота или в папке <code>handlers</code>.\n\n"
|
||||||
|
"📁 Выберите директорию, а затем отправьте файл с таким же именем и расширением, "
|
||||||
|
"как у уже существующего файла. Он будет автоматически заменён."
|
||||||
|
)
|
||||||
|
|
||||||
|
back_kb = build_admin_back_kb("management")
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text="📁 Корень бота", callback_data="upload_target:root")
|
||||||
|
kb.button(text="📂 Папка handlers", callback_data="upload_target:handlers")
|
||||||
|
for row in back_kb.inline_keyboard:
|
||||||
|
kb.row(*row)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
await state.set_state(FileUploadState.waiting_for_target)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("upload_target:"), FileUploadState.waiting_for_target)
|
||||||
|
async def select_upload_target(callback: CallbackQuery, state: FSMContext):
|
||||||
|
target = callback.data.split(":", 1)[1]
|
||||||
|
if target not in {"root", "handlers"}:
|
||||||
|
await callback.answer("Неизвестная директория.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(upload_target=target)
|
||||||
|
|
||||||
|
target_text = "Корень бота" if target == "root" else "Папка handlers"
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📤 <b>Загрузка файла</b>\n\n"
|
||||||
|
f"Выбрана директория: <b>{target_text}</b>.\n\n"
|
||||||
|
"Теперь отправьте файл с таким же именем и расширением, как у уже существующего файла. "
|
||||||
|
"Он будет автоматически заменён.",
|
||||||
|
reply_markup=build_admin_back_kb("management"),
|
||||||
|
)
|
||||||
|
await state.set_state(FileUploadState.waiting_for_file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(FileUploadState.waiting_for_file, F.document)
|
||||||
|
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
||||||
|
document = message.document
|
||||||
|
file_name = document.file_name
|
||||||
|
|
||||||
|
if not file_name or "." not in file_name:
|
||||||
|
await message.answer("❌ У файла должно быть имя с расширением.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target = data.get("upload_target", "root")
|
||||||
|
|
||||||
|
if target == "handlers":
|
||||||
|
base_dir = os.path.abspath("./handlers")
|
||||||
|
else:
|
||||||
|
base_dir = os.path.abspath(".")
|
||||||
|
|
||||||
|
os.makedirs(base_dir, exist_ok=True)
|
||||||
|
dest_path = os.path.join(base_dir, file_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.bot.download(document, destination=dest_path)
|
||||||
|
|
||||||
|
back_kb = build_admin_back_kb("management")
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(
|
||||||
|
text="🔁 Перезагрузить бота",
|
||||||
|
callback_data=AdminPanelCallback(action="restart").pack(),
|
||||||
|
)
|
||||||
|
for row in back_kb.inline_keyboard:
|
||||||
|
kb.row(*row)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён в директории <code>{target}</code>.\n\n"
|
||||||
|
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Не удалось сохранить файл: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("management"),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key
|
||||||
|
from handlers.keys.operations import update_subscription
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_post_import_kb
|
||||||
|
|
||||||
|
|
||||||
|
class Import3xuiStates(StatesGroup):
|
||||||
|
waiting_for_file = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
|
||||||
|
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
|
||||||
|
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
|
||||||
|
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
|
||||||
|
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
await state.set_state(Import3xuiStates.waiting_for_file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Import3xuiStates.waiting_for_file, F.document)
|
||||||
|
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
file = message.document
|
||||||
|
|
||||||
|
if not file.file_name.endswith(".db"):
|
||||||
|
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = f"/tmp/{file.file_name}"
|
||||||
|
await message.bot.download(file, destination=file_path)
|
||||||
|
|
||||||
|
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database.importer import import_keys_from_3xui_db
|
||||||
|
|
||||||
|
imported, skipped = await import_keys_from_3xui_db(file_path, session)
|
||||||
|
|
||||||
|
await processing_message.edit_text(
|
||||||
|
f"✅ Восстановление завершено:\n"
|
||||||
|
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
|
||||||
|
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
|
||||||
|
reply_markup=build_post_import_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Import 3x-ui] Ошибка: {e}")
|
||||||
|
await processing_message.edit_text(
|
||||||
|
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
|
||||||
|
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
||||||
|
|
||||||
|
result = await session.execute(select(Key.tg_id, Key.email))
|
||||||
|
keys = result.all()
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for tg_id, email in keys:
|
||||||
|
try:
|
||||||
|
await update_subscription(tg_id=tg_id, email=email, session=session)
|
||||||
|
success += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from dateutil import parser
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||||
|
from database.models import Key, Server, User
|
||||||
|
from logger import logger
|
||||||
|
from panels.remnawave import RemnawaveAPI
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback, build_back_to_db_menu
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
|
||||||
|
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
||||||
|
servers = result.scalars().all()
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Нет доступных Remnawave-серверов.",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
server = servers[0]
|
||||||
|
|
||||||
|
api = RemnawaveAPI(base_url=server.api_url)
|
||||||
|
|
||||||
|
users = await api.get_all_users_time(
|
||||||
|
username=REMNAWAVE_LOGIN,
|
||||||
|
password=REMNAWAVE_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📭 На панели нет клиентов.",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
added_users = await import_remnawave_users(session, users)
|
||||||
|
|
||||||
|
server_id = server.cluster_name or server.server_name
|
||||||
|
|
||||||
|
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
||||||
|
|
||||||
|
preview = ""
|
||||||
|
for i, user in enumerate(users[:3], 1):
|
||||||
|
email = user.get("email") or user.get("username") or "-"
|
||||||
|
expire = user.get("expireAt", "")[:10]
|
||||||
|
preview += f"{i}. {email} — до {expire}\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
|
||||||
|
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
|
||||||
|
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
|
||||||
|
f"<b>Первые 3:</b>\n{preview}",
|
||||||
|
reply_markup=build_back_to_db_menu(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
|
||||||
|
added = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
tg_id = user.get("telegramId")
|
||||||
|
if not tg_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||||
|
if exists.scalar():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_user = User(
|
||||||
|
tg_id=tg_id,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
last_name=None,
|
||||||
|
language_code=None,
|
||||||
|
is_bot=False,
|
||||||
|
balance=0.0,
|
||||||
|
trial=1,
|
||||||
|
source_code=None,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
session.add(new_user)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
|
||||||
|
added = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
tg_id = user.get("telegramId")
|
||||||
|
client_id = user.get("uuid")
|
||||||
|
email = user.get("email") or user.get("username")
|
||||||
|
remnawave_link = user.get("subscriptionUrl")
|
||||||
|
expire_at = user.get("expireAt")
|
||||||
|
created_at = user.get("createdAt")
|
||||||
|
|
||||||
|
if not tg_id or not client_id:
|
||||||
|
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||||
|
if exists_stmt.scalar():
|
||||||
|
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
||||||
|
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
||||||
|
|
||||||
|
new_key = Key(
|
||||||
|
tg_id=tg_id,
|
||||||
|
client_id=client_id,
|
||||||
|
email=email,
|
||||||
|
created_at=created_ts,
|
||||||
|
expiry_time=expire_ts,
|
||||||
|
key="",
|
||||||
|
server_id=server_id,
|
||||||
|
remnawave_link=remnawave_link,
|
||||||
|
tariff_id=None,
|
||||||
|
is_frozen=False,
|
||||||
|
alias=None,
|
||||||
|
notified=False,
|
||||||
|
notified_24h=False,
|
||||||
|
)
|
||||||
|
session.add(new_key)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
||||||
|
return added
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from middlewares import maintenance
|
from core.bootstrap import MANAGEGENT_CONFIG
|
||||||
|
|
||||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +38,9 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
|
|||||||
text="📤 Загрузить файл",
|
text="📤 Загрузить файл",
|
||||||
callback_data=AdminPanelCallback(action="upload_file").pack(),
|
callback_data=AdminPanelCallback(action="upload_file").pack(),
|
||||||
)
|
)
|
||||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
|
||||||
|
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||||
|
maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
|
||||||
builder.button(
|
builder.button(
|
||||||
text=maintenance_text,
|
text=maintenance_text,
|
||||||
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||||
@@ -72,7 +73,8 @@ def build_database_kb() -> InlineKeyboardMarkup:
|
|||||||
|
|
||||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
|
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||||
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +107,6 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
|
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
|
||||||
|
|
||||||
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
|
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
|
||||||
|
|
||||||
if role == "superadmin":
|
if role == "superadmin":
|
||||||
@@ -143,14 +144,6 @@ def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
|
|
||||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
|
||||||
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def build_post_import_kb() -> InlineKeyboardMarkup:
|
def build_post_import_kb() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(
|
builder.button(
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from aiogram import F
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import MANAGEGENT_CONFIG, update_management_config
|
||||||
|
from database.models import Admin
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminPanelCallback, build_management_kb
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||||
|
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
tg_id = callback_query.from_user.id
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🤖 Управление ботом",
|
||||||
|
reply_markup=build_management_kb(admin.role),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||||
|
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tg_id = callback.from_user.id
|
||||||
|
|
||||||
|
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_config = dict(MANAGEGENT_CONFIG)
|
||||||
|
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
|
||||||
|
new_value = not current_value
|
||||||
|
current_config["MAINTENANCE_ENABLED"] = new_value
|
||||||
|
|
||||||
|
await update_management_config(session, current_config)
|
||||||
|
|
||||||
|
new_status = "включён" if new_value else "выключен"
|
||||||
|
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||||
|
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from asyncio import sleep
|
|
||||||
from datetime import datetime
|
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
|
|
||||||
from aiogram import Bot, F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
|
||||||
from aiogram.types import CallbackQuery, Message
|
|
||||||
from dateutil import parser
|
|
||||||
from sqlalchemy import delete, func, select, update
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
|
||||||
from database.models import Admin, Key, Server, User
|
|
||||||
from filters.admin import IsAdminFilter
|
|
||||||
from handlers.keys.operations import update_subscription
|
|
||||||
from logger import logger
|
|
||||||
from middlewares import maintenance
|
|
||||||
from panels.remnawave import RemnawaveAPI
|
|
||||||
|
|
||||||
from ..panel.keyboard import build_admin_back_kb
|
|
||||||
from .keyboard import (
|
|
||||||
AdminPanelCallback,
|
|
||||||
build_admin_back_kb_to_admins,
|
|
||||||
build_admins_kb,
|
|
||||||
build_back_to_db_menu,
|
|
||||||
build_database_kb,
|
|
||||||
build_export_db_sources_kb,
|
|
||||||
build_management_kb,
|
|
||||||
build_post_import_kb,
|
|
||||||
build_role_selection_kb,
|
|
||||||
build_single_admin_menu,
|
|
||||||
build_token_result_kb,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminManagementStates(StatesGroup):
|
|
||||||
waiting_for_new_domain = State()
|
|
||||||
|
|
||||||
|
|
||||||
class Import3xuiStates(StatesGroup):
|
|
||||||
waiting_for_file = State()
|
|
||||||
|
|
||||||
|
|
||||||
class FileUploadState(StatesGroup):
|
|
||||||
waiting_for_file = State()
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseState(StatesGroup):
|
|
||||||
waiting_for_backup_file = State()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminState(StatesGroup):
|
|
||||||
waiting_for_tg_id = State()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
|
||||||
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not admin:
|
|
||||||
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback_query.message.edit_text(
|
|
||||||
text="🤖 Управление ботом",
|
|
||||||
reply_markup=build_management_kb(admin.role),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
|
||||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
|
||||||
"""Запрашивает у администратора новый домен."""
|
|
||||||
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
|
||||||
await callback_query.message.edit_text(
|
|
||||||
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminManagementStates.waiting_for_new_domain)
|
|
||||||
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
|
|
||||||
"""Обновляет домен в таблице keys."""
|
|
||||||
new_domain = message.text.strip()
|
|
||||||
|
|
||||||
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
|
|
||||||
logger.warning("[DomainChange] Некорректный домен")
|
|
||||||
await message.answer(
|
|
||||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
|
||||||
reply_markup=build_admin_back_kb("admin"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
new_domain_url = f"https://{new_domain}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
stmt = (
|
|
||||||
update(Key)
|
|
||||||
.values(
|
|
||||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
|
||||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
|
||||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
|
||||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
|
||||||
await message.answer(
|
|
||||||
f"❌ Ошибка при обновлении домена: {e}",
|
|
||||||
reply_markup=build_admin_back_kb("admin"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
|
|
||||||
example = sample.fetchone()
|
|
||||||
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
f"✅ Домен успешно изменен на {new_domain}!",
|
|
||||||
reply_markup=build_admin_back_kb("admin"),
|
|
||||||
)
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
|
||||||
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
tg_id = callback.from_user.id
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not admin:
|
|
||||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
|
||||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
|
||||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
|
||||||
|
|
||||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
|
|
||||||
async def show_admins(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
result = await session.execute(select(Admin.tg_id, Admin.role))
|
|
||||||
admins = result.all()
|
|
||||||
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
|
|
||||||
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
|
|
||||||
)
|
|
||||||
await state.set_state(AdminState.waiting_for_tg_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminState.waiting_for_tg_id)
|
|
||||||
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
|
|
||||||
try:
|
|
||||||
tg_id = int(message.text.strip())
|
|
||||||
except ValueError:
|
|
||||||
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
|
|
||||||
return
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
if result.scalar_one_or_none():
|
|
||||||
await message.answer("⚠️ Такой админ уже существует.")
|
|
||||||
else:
|
|
||||||
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
|
|
||||||
await session.commit()
|
|
||||||
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
|
|
||||||
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
|
||||||
tg_id = int(callback_data.action.split("|")[1])
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
role = admin or "moderator"
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
|
|
||||||
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
|
||||||
tg_id = int(callback_data.action.split("|")[1])
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
if not admin:
|
|
||||||
await callback.message.edit_text("❌ Админ не найден.")
|
|
||||||
return
|
|
||||||
|
|
||||||
token = Admin.generate_token()
|
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
admin.token = token_hash
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
msg = await callback.message.edit_text(
|
|
||||||
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
|
|
||||||
f"<code>{token}</code>\n\n"
|
|
||||||
f"⚠️ Это сообщение исчезнет через 5 минут.",
|
|
||||||
reply_markup=build_token_result_kb(token),
|
|
||||||
)
|
|
||||||
|
|
||||||
await sleep(300)
|
|
||||||
try:
|
|
||||||
await msg.delete()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
|
|
||||||
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
|
|
||||||
tg_id = int(callback_data.action.split("|")[1])
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
|
|
||||||
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
|
||||||
try:
|
|
||||||
_, tg_id_str, role = callback_data.action.split("|")
|
|
||||||
tg_id = int(tg_id_str)
|
|
||||||
if role not in ("superadmin", "moderator"):
|
|
||||||
raise ValueError
|
|
||||||
except Exception:
|
|
||||||
await callback.message.edit_text("❌ Неверный формат.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if tg_id == callback.from_user.id:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
if not admin:
|
|
||||||
await callback.message.edit_text("❌ Админ не найден.")
|
|
||||||
return
|
|
||||||
|
|
||||||
admin.role = role
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
|
|
||||||
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
|
||||||
tg_id = int(callback_data.action.split("|")[1])
|
|
||||||
|
|
||||||
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
|
|
||||||
async def handle_database_menu(callback: CallbackQuery):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text="🗄 <b>Управление базой данных</b>",
|
|
||||||
reply_markup=build_database_kb(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
|
|
||||||
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
|
|
||||||
"⚠️ Все текущие данные будут перезаписаны.",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
await state.set_state(DatabaseState.waiting_for_backup_file)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(DatabaseState.waiting_for_backup_file)
|
|
||||||
async def restore_database(message: Message, state: FSMContext, bot: Bot):
|
|
||||||
document = message.document
|
|
||||||
|
|
||||||
if not document or not document.file_name.endswith(".sql"):
|
|
||||||
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
|
|
||||||
tmp_path = tmp_file.name
|
|
||||||
|
|
||||||
await bot.download(document, destination=tmp_path)
|
|
||||||
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
|
|
||||||
|
|
||||||
is_custom_dump = False
|
|
||||||
with open(tmp_path, "rb") as f:
|
|
||||||
signature = f.read(5)
|
|
||||||
if signature == b"PGDMP":
|
|
||||||
is_custom_dump = True
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"sudo",
|
|
||||||
"-u",
|
|
||||||
"postgres",
|
|
||||||
"psql",
|
|
||||||
"-d",
|
|
||||||
"postgres",
|
|
||||||
"-c",
|
|
||||||
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("[Restore] База данных пересоздана")
|
|
||||||
|
|
||||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
|
||||||
|
|
||||||
if is_custom_dump:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"pg_restore",
|
|
||||||
f"--dbname={DB_NAME}",
|
|
||||||
"-U",
|
|
||||||
DB_USER,
|
|
||||||
"-h",
|
|
||||||
PG_HOST,
|
|
||||||
"-p",
|
|
||||||
PG_PORT,
|
|
||||||
"--no-owner",
|
|
||||||
"--exit-on-error",
|
|
||||||
tmp_path,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"psql",
|
|
||||||
"-U",
|
|
||||||
DB_USER,
|
|
||||||
"-h",
|
|
||||||
PG_HOST,
|
|
||||||
"-p",
|
|
||||||
PG_PORT,
|
|
||||||
"-d",
|
|
||||||
DB_NAME,
|
|
||||||
"-f",
|
|
||||||
tmp_path,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
del os.environ["PGPASSWORD"]
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
|
|
||||||
await message.answer(
|
|
||||||
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
"✅ База данных восстановлена.",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
|
|
||||||
await state.clear()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
|
|
||||||
await message.answer(
|
|
||||||
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
|
|
||||||
async def handle_export_db(callback: CallbackQuery):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"📤 Выберите панель, с которой требуется получить данные:\n\n"
|
|
||||||
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
|
|
||||||
reply_markup=build_export_db_sources_kb(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
|
|
||||||
async def back_to_database_menu(callback: CallbackQuery):
|
|
||||||
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
|
|
||||||
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
|
||||||
servers = result.scalars().all()
|
|
||||||
|
|
||||||
if not servers:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"❌ Нет доступных Remnawave-серверов.",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
server = servers[0]
|
|
||||||
|
|
||||||
api = RemnawaveAPI(base_url=server.api_url)
|
|
||||||
|
|
||||||
users = await api.get_all_users_time(
|
|
||||||
username=REMNAWAVE_LOGIN,
|
|
||||||
password=REMNAWAVE_PASSWORD,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not users:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"📭 На панели нет клиентов.",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
|
||||||
|
|
||||||
added_users = await import_remnawave_users(session, users)
|
|
||||||
|
|
||||||
server_id = server.cluster_name or server.server_name
|
|
||||||
|
|
||||||
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
|
||||||
|
|
||||||
preview = ""
|
|
||||||
for i, user in enumerate(users[:3], 1):
|
|
||||||
email = user.get("email") or user.get("username") or "-"
|
|
||||||
expire = user.get("expireAt", "")[:10]
|
|
||||||
preview += f"{i}. {email} — до {expire}\n"
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
|
|
||||||
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
|
|
||||||
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
|
|
||||||
f"<b>Первые 3:</b>\n{preview}",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
|
|
||||||
added = 0
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
tg_id = user.get("telegramId")
|
|
||||||
if not tg_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
|
||||||
if exists.scalar():
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_user = User(
|
|
||||||
tg_id=tg_id,
|
|
||||||
username=None,
|
|
||||||
first_name=None,
|
|
||||||
last_name=None,
|
|
||||||
language_code=None,
|
|
||||||
is_bot=False,
|
|
||||||
balance=0.0,
|
|
||||||
trial=1,
|
|
||||||
source_code=None,
|
|
||||||
created_at=datetime.utcnow(),
|
|
||||||
updated_at=datetime.utcnow(),
|
|
||||||
)
|
|
||||||
session.add(new_user)
|
|
||||||
added += 1
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
return added
|
|
||||||
|
|
||||||
|
|
||||||
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
|
|
||||||
added = 0
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
tg_id = user.get("telegramId")
|
|
||||||
client_id = user.get("uuid")
|
|
||||||
email = user.get("email") or user.get("username")
|
|
||||||
remnawave_link = user.get("subscriptionUrl")
|
|
||||||
expire_at = user.get("expireAt")
|
|
||||||
created_at = user.get("createdAt")
|
|
||||||
|
|
||||||
if not tg_id or not client_id:
|
|
||||||
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
|
||||||
if exists_stmt.scalar():
|
|
||||||
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
|
||||||
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
|
||||||
|
|
||||||
new_key = Key(
|
|
||||||
tg_id=tg_id,
|
|
||||||
client_id=client_id,
|
|
||||||
email=email,
|
|
||||||
created_at=created_ts,
|
|
||||||
expiry_time=expire_ts,
|
|
||||||
key="",
|
|
||||||
server_id=server_id,
|
|
||||||
remnawave_link=remnawave_link,
|
|
||||||
tariff_id=None,
|
|
||||||
is_frozen=False,
|
|
||||||
alias=None,
|
|
||||||
notified=False,
|
|
||||||
notified_24h=False,
|
|
||||||
)
|
|
||||||
session.add(new_key)
|
|
||||||
added += 1
|
|
||||||
|
|
||||||
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
|
||||||
return added
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
|
|
||||||
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
|
|
||||||
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
|
|
||||||
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
|
|
||||||
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
await state.set_state(Import3xuiStates.waiting_for_file)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Import3xuiStates.waiting_for_file, F.document)
|
|
||||||
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
|
|
||||||
file = message.document
|
|
||||||
|
|
||||||
if not file.file_name.endswith(".db"):
|
|
||||||
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
|
|
||||||
return
|
|
||||||
|
|
||||||
file_path = f"/tmp/{file.file_name}"
|
|
||||||
await message.bot.download(file, destination=file_path)
|
|
||||||
|
|
||||||
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from database.importer import import_keys_from_3xui_db
|
|
||||||
|
|
||||||
imported, skipped = await import_keys_from_3xui_db(file_path, session)
|
|
||||||
|
|
||||||
await processing_message.edit_text(
|
|
||||||
f"✅ Восстановление завершено:\n"
|
|
||||||
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
|
|
||||||
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
|
|
||||||
reply_markup=build_post_import_kb(),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Import 3x-ui] Ошибка: {e}")
|
|
||||||
await processing_message.edit_text(
|
|
||||||
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
|
|
||||||
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
|
||||||
|
|
||||||
result = await session.execute(select(Key.tg_id, Key.email))
|
|
||||||
keys = result.all()
|
|
||||||
|
|
||||||
success = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for tg_id, email in keys:
|
|
||||||
try:
|
|
||||||
await update_subscription(tg_id=tg_id, email=email, session=session)
|
|
||||||
success += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
|
||||||
reply_markup=build_back_to_db_menu(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
|
|
||||||
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"📤 <b>Загрузка файла</b>\n\n"
|
|
||||||
"Вы можете заменить файл в корневой директории бота.\n\n"
|
|
||||||
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
|
|
||||||
"как у уже существующего файла. Он будет автоматически заменён.",
|
|
||||||
reply_markup=build_admin_back_kb("management"),
|
|
||||||
)
|
|
||||||
await state.set_state(FileUploadState.waiting_for_file)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(FileUploadState.waiting_for_file, F.document)
|
|
||||||
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
|
||||||
document = message.document
|
|
||||||
file_name = document.file_name
|
|
||||||
|
|
||||||
if not file_name or "." not in file_name:
|
|
||||||
await message.answer("❌ У файла должно быть имя с расширением.")
|
|
||||||
return
|
|
||||||
|
|
||||||
dest_path = os.path.abspath(f"./{file_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await message.bot.download(document, destination=dest_path)
|
|
||||||
await message.answer(
|
|
||||||
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
|
|
||||||
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
|
||||||
reply_markup=build_admin_back_kb("management"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
|
||||||
await message.answer(
|
|
||||||
f"❌ Не удалось сохранить файл: {e}",
|
|
||||||
reply_markup=build_admin_back_kb("management"),
|
|
||||||
)
|
|
||||||
await state.clear()
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from math import ceil
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|||||||
@@ -66,15 +66,20 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
|
|||||||
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
|
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
|
||||||
builder = insert_hook_buttons(builder, module_buttons)
|
builder = insert_hook_buttons(builder, module_buttons)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="⚙️ Настройки",
|
||||||
|
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||||
|
)
|
||||||
|
|
||||||
builder.button(
|
builder.button(
|
||||||
text=MAIN_MENU,
|
text=MAIN_MENU,
|
||||||
callback_data="profile",
|
callback_data="profile",
|
||||||
)
|
)
|
||||||
|
|
||||||
if admin_role == "superadmin":
|
if admin_role == "superadmin":
|
||||||
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
|
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1, 1)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 2, 1, 1)
|
builder.adjust(2, 2, 1, 1, 1)
|
||||||
|
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
__all__ = ("router",)
|
from . import (
|
||||||
|
server_manage, # noqa: F401
|
||||||
from .servers_handler import router
|
server_wizard, # noqa: F401
|
||||||
|
)
|
||||||
|
from .server_states import router
|
||||||
|
|||||||
+53
-185
@@ -1,44 +1,18 @@
|
|||||||
from aiogram import F, Router, types
|
from aiogram import F, types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.types import CallbackQuery
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_servers
|
from database import get_servers
|
||||||
from database.models import Key, Server
|
from database.models import Key, Server
|
||||||
from database.servers import (
|
|
||||||
get_available_clusters,
|
|
||||||
get_server_by_name,
|
|
||||||
update_server_cluster,
|
|
||||||
update_server_field,
|
|
||||||
update_server_name_with_keys,
|
|
||||||
)
|
|
||||||
from filters.admin import IsAdminFilter
|
from filters.admin import IsAdminFilter
|
||||||
from handlers.buttons import BACK
|
from handlers.buttons import BACK
|
||||||
|
|
||||||
from ..panel.keyboard import build_admin_back_kb
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
from .keyboard import (
|
from .keyboard import AdminServerCallback, build_manage_server_kb
|
||||||
AdminServerCallback,
|
from .server_states import ServerLimitState, router
|
||||||
build_cancel_edit_kb,
|
|
||||||
build_cluster_selection_kb,
|
|
||||||
build_edit_server_fields_kb,
|
|
||||||
build_manage_server_kb,
|
|
||||||
build_panel_type_selection_kb,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
class ServerLimitState(StatesGroup):
|
|
||||||
waiting_for_limit = State()
|
|
||||||
|
|
||||||
|
|
||||||
class ServerEditState(StatesGroup):
|
|
||||||
choosing_field = State()
|
|
||||||
editing_value = State()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||||
@@ -102,11 +76,19 @@ async def process_callback_delete_server(
|
|||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
):
|
):
|
||||||
from ..clusters.clusters_handler import AdminClusterStates
|
from sqlalchemy import delete as sa_delete
|
||||||
|
|
||||||
|
from database import get_servers as get_servers_inner
|
||||||
|
from database.models import (
|
||||||
|
Key as KeyModel,
|
||||||
|
Server as ServerModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..clusters.base import AdminClusterStates
|
||||||
|
|
||||||
server_name = callback_data.data
|
server_name = callback_data.data
|
||||||
|
|
||||||
servers_dict = await get_servers(session, include_enabled=True)
|
servers_dict = await get_servers_inner(session, include_enabled=True)
|
||||||
cluster_name = None
|
cluster_name = None
|
||||||
for c_name, server_list in servers_dict.items():
|
for c_name, server_list in servers_dict.items():
|
||||||
if any(s["server_name"] == server_name for s in server_list):
|
if any(s["server_name"] == server_name for s in server_list):
|
||||||
@@ -120,16 +102,23 @@ async def process_callback_delete_server(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
|
stmt_keys_count = select(func.count()).where(KeyModel.server_id == server_name)
|
||||||
result = await session.execute(stmt_keys_count)
|
result = await session.execute(stmt_keys_count)
|
||||||
keys_count = result.scalar_one()
|
keys_count = result.scalar_one()
|
||||||
|
|
||||||
if keys_count > 0:
|
if keys_count > 0:
|
||||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||||
|
|
||||||
subq = select(func.count()).where(Key.server_id == Server.server_name).correlate(Server).scalar_subquery()
|
subq = (
|
||||||
|
select(func.count())
|
||||||
|
.where(KeyModel.server_id == ServerModel.server_name)
|
||||||
|
.correlate(ServerModel)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(Server.server_name != server_name)
|
stmt_all_servers = select(ServerModel.server_name, subq.label("key_count")).where(
|
||||||
|
ServerModel.server_name != server_name
|
||||||
|
)
|
||||||
result = await session.execute(stmt_all_servers)
|
result = await session.execute(stmt_all_servers)
|
||||||
all_servers = result.all()
|
all_servers = result.all()
|
||||||
|
|
||||||
@@ -139,20 +128,22 @@ async def process_callback_delete_server(
|
|||||||
callback_data = f"transfer_to_server|{s_name}|{server_name}"
|
callback_data = f"transfer_to_server|{s_name}|{server_name}"
|
||||||
if len(callback_data.encode("utf-8")) > 64:
|
if len(callback_data.encode("utf-8")) > 64:
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(
|
||||||
text=f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
text=(
|
||||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
||||||
|
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||||
|
),
|
||||||
reply_markup=build_admin_back_kb("clusters"),
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=f"{s_name} ({key_count})",
|
text=f"{s_name} ({key_count})",
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=BACK,
|
text=BACK,
|
||||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||||
)
|
)
|
||||||
@@ -182,6 +173,8 @@ async def process_callback_delete_server(
|
|||||||
cluster_keys_count = result.scalar_one()
|
cluster_keys_count = result.scalar_one()
|
||||||
|
|
||||||
if cluster_keys_count > 0:
|
if cluster_keys_count > 0:
|
||||||
|
from ..clusters.base import AdminClusterStates
|
||||||
|
|
||||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||||
|
|
||||||
subq_cluster = (
|
subq_cluster = (
|
||||||
@@ -201,41 +194,53 @@ async def process_callback_delete_server(
|
|||||||
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
|
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
|
||||||
if len(callback_data.encode("utf-8")) > 64:
|
if len(callback_data.encode("utf-8")) > 64:
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(
|
||||||
text=f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
text=(
|
||||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
||||||
|
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||||
|
),
|
||||||
reply_markup=build_admin_back_kb("clusters"),
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=f"{cl_name} ({key_count})",
|
text=f"{cl_name} ({key_count})",
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=BACK,
|
text=BACK,
|
||||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(
|
||||||
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
|
text=(
|
||||||
|
f"⚠️ Это последний сервер в кластере '{cluster_name}'. "
|
||||||
|
f"На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:"
|
||||||
|
),
|
||||||
reply_markup=builder.as_markup(),
|
reply_markup=builder.as_markup(),
|
||||||
)
|
)
|
||||||
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||||
return
|
return
|
||||||
|
|
||||||
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
|
stmt_delete = sa_delete(Server).where(
|
||||||
|
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||||
|
)
|
||||||
await session.execute(stmt_delete)
|
await session.execute(stmt_delete)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(
|
||||||
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
|
text=(
|
||||||
|
f"✅ Сервер '{server_name}' удален. "
|
||||||
|
f"Кластер '{cluster_name}' также удален, так как в нем не осталось серверов."
|
||||||
|
),
|
||||||
reply_markup=build_admin_back_kb("clusters"),
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
|
stmt_delete = sa_delete(Server).where(
|
||||||
|
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||||
|
)
|
||||||
await session.execute(stmt_delete)
|
await session.execute(stmt_delete)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await callback_query.message.edit_text(
|
await callback_query.message.edit_text(
|
||||||
@@ -341,140 +346,3 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
|
|||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
|
|
||||||
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
|
||||||
server_name = callback.data.split("|")[1]
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
server_data = await get_server_by_name(session, server_name)
|
|
||||||
if not server_data:
|
|
||||||
await callback.message.edit_text("❌ Сервер не найден.")
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
|
|
||||||
reply_markup=build_edit_server_fields_kb(server_name, server_data),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
|
|
||||||
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
|
||||||
_, server_name, field = callback.data.split("|")
|
|
||||||
|
|
||||||
if field == "cluster_name":
|
|
||||||
clusters = await get_available_clusters(session)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
|
|
||||||
reply_markup=build_cluster_selection_kb(server_name, clusters),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await state.update_data(server_name=server_name, field=field)
|
|
||||||
await state.set_state(ServerEditState.editing_value)
|
|
||||||
|
|
||||||
field_names = {
|
|
||||||
"server_name": "имя сервера",
|
|
||||||
"api_url": "API URL",
|
|
||||||
"subscription_url": "Subscription URL",
|
|
||||||
"inbound_id": "Inbound ID/Squads",
|
|
||||||
}
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
|
||||||
reply_markup=build_cancel_edit_kb(server_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
|
|
||||||
async def select_panel_type(callback: CallbackQuery):
|
|
||||||
server_name = callback.data.split("|")[1]
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
|
|
||||||
reply_markup=build_panel_type_selection_kb(server_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
|
|
||||||
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
_, server_name, panel_type = callback.data.split("|")
|
|
||||||
|
|
||||||
success = await update_server_field(session, server_name, "panel_type", panel_type)
|
|
||||||
if success:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
|
|
||||||
reply_markup=InlineKeyboardBuilder()
|
|
||||||
.button(
|
|
||||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
|
||||||
)
|
|
||||||
.as_markup(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
|
|
||||||
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
_, server_name, new_cluster = callback.data.split("|")
|
|
||||||
|
|
||||||
success = await update_server_cluster(session, server_name, new_cluster)
|
|
||||||
if success:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
|
|
||||||
reply_markup=InlineKeyboardBuilder()
|
|
||||||
.button(
|
|
||||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
|
||||||
)
|
|
||||||
.as_markup(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.message.edit_text("❌ Ошибка при изменении кластера")
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(ServerEditState.editing_value, IsAdminFilter())
|
|
||||||
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
|
|
||||||
data = await state.get_data()
|
|
||||||
server_name = data["server_name"]
|
|
||||||
field = data["field"]
|
|
||||||
value = message.text.strip()
|
|
||||||
|
|
||||||
if field == "server_name":
|
|
||||||
if len(value) > 12:
|
|
||||||
await message.answer(
|
|
||||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
|
||||||
reply_markup=build_admin_back_kb("clusters"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
success = await update_server_name_with_keys(session, server_name, value)
|
|
||||||
if success:
|
|
||||||
server_name = value
|
|
||||||
else:
|
|
||||||
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
success = await update_server_field(session, server_name, field, value)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
field_names = {
|
|
||||||
"server_name": "имя сервера",
|
|
||||||
"api_url": "API URL",
|
|
||||||
"subscription_url": "Subscription URL",
|
|
||||||
"inbound_id": "Inbound ID/Squads",
|
|
||||||
}
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
f"✅ {field_names.get(field, field).capitalize()} изменено",
|
|
||||||
reply_markup=InlineKeyboardBuilder()
|
|
||||||
.button(
|
|
||||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
|
||||||
)
|
|
||||||
.as_markup(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await message.answer("❌ Ошибка при изменении поля")
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class ServerLimitState(StatesGroup):
|
||||||
|
waiting_for_limit = State()
|
||||||
|
|
||||||
|
|
||||||
|
class ServerEditState(StatesGroup):
|
||||||
|
choosing_field = State()
|
||||||
|
editing_value = State()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from aiogram import F, types
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.servers import (
|
||||||
|
get_available_clusters,
|
||||||
|
get_server_by_name,
|
||||||
|
update_server_cluster,
|
||||||
|
update_server_field,
|
||||||
|
update_server_name_with_keys,
|
||||||
|
)
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .keyboard import (
|
||||||
|
AdminServerCallback,
|
||||||
|
build_cancel_edit_kb,
|
||||||
|
build_cluster_selection_kb,
|
||||||
|
build_edit_server_fields_kb,
|
||||||
|
build_panel_type_selection_kb,
|
||||||
|
)
|
||||||
|
from .server_states import ServerEditState, router
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
|
||||||
|
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
server_name = callback.data.split("|")[1]
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
server_data = await get_server_by_name(session, server_name)
|
||||||
|
if not server_data:
|
||||||
|
await callback.message.edit_text("❌ Сервер не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
|
||||||
|
reply_markup=build_edit_server_fields_kb(server_name, server_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
|
||||||
|
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, server_name, field = callback.data.split("|")
|
||||||
|
|
||||||
|
if field == "cluster_name":
|
||||||
|
clusters = await get_available_clusters(session)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
|
||||||
|
reply_markup=build_cluster_selection_kb(server_name, clusters),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(server_name=server_name, field=field)
|
||||||
|
await state.set_state(ServerEditState.editing_value)
|
||||||
|
|
||||||
|
field_names = {
|
||||||
|
"server_name": "имя сервера",
|
||||||
|
"api_url": "API URL",
|
||||||
|
"subscription_url": "Subscription URL",
|
||||||
|
"inbound_id": "Inbound ID/Squads",
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||||
|
reply_markup=build_cancel_edit_kb(server_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
|
||||||
|
async def select_panel_type(callback: CallbackQuery):
|
||||||
|
server_name = callback.data.split("|")[1]
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
|
||||||
|
reply_markup=build_panel_type_selection_kb(server_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
|
||||||
|
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
_, server_name, panel_type = callback.data.split("|")
|
||||||
|
|
||||||
|
success = await update_server_field(session, server_name, "panel_type", panel_type)
|
||||||
|
if success:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
|
||||||
|
reply_markup=InlineKeyboardBuilder()
|
||||||
|
.button(
|
||||||
|
text="⬅️ Назад к серверу",
|
||||||
|
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
.as_markup(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
|
||||||
|
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
_, server_name, new_cluster = callback.data.split("|")
|
||||||
|
|
||||||
|
success = await update_server_cluster(session, server_name, new_cluster)
|
||||||
|
if success:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
|
||||||
|
reply_markup=InlineKeyboardBuilder()
|
||||||
|
.button(
|
||||||
|
text="⬅️ Назад к серверу",
|
||||||
|
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
.as_markup(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback.message.edit_text("❌ Ошибка при изменении кластера")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(ServerEditState.editing_value, IsAdminFilter())
|
||||||
|
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
server_name = data["server_name"]
|
||||||
|
field = data["field"]
|
||||||
|
value = message.text.strip()
|
||||||
|
|
||||||
|
if field == "server_name":
|
||||||
|
if len(value) > 12:
|
||||||
|
await message.answer(
|
||||||
|
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await update_server_name_with_keys(session, server_name, value)
|
||||||
|
if success:
|
||||||
|
server_name = value
|
||||||
|
else:
|
||||||
|
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
success = await update_server_field(session, server_name, field, value)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
field_names = {
|
||||||
|
"server_name": "имя сервера",
|
||||||
|
"api_url": "API URL",
|
||||||
|
"subscription_url": "Subscription URL",
|
||||||
|
"inbound_id": "Inbound ID/Squads",
|
||||||
|
}
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ {field_names.get(field, field).capitalize()} изменено",
|
||||||
|
reply_markup=InlineKeyboardBuilder()
|
||||||
|
.button(
|
||||||
|
text="⬅️ Назад к серверу",
|
||||||
|
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
.as_markup(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer("❌ Ошибка при изменении поля")
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .settings_buttons import router as settings_buttons_router
|
||||||
|
from .settings_cashboxes import router as settings_cashboxes_router
|
||||||
|
from .settings_manage import router as settings_manage_router
|
||||||
|
from .settings_modes import router as settings_modes_router
|
||||||
|
from .settings_money import router as settings_panels_router
|
||||||
|
from .settings_notifications import router as settings_notifications_router
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings")
|
||||||
|
router.include_router(settings_manage_router)
|
||||||
|
router.include_router(settings_buttons_router)
|
||||||
|
router.include_router(settings_cashboxes_router)
|
||||||
|
router.include_router(settings_panels_router)
|
||||||
|
router.include_router(settings_notifications_router)
|
||||||
|
router.include_router(settings_modes_router)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
from .settings_config import (
|
||||||
|
BUTTON_TITLES,
|
||||||
|
NOTIFICATION_TITLES,
|
||||||
|
NOTIFICATION_TIME_FIELDS,
|
||||||
|
PAYMENT_PROVIDER_TITLES,
|
||||||
|
MODES_TITLES,
|
||||||
|
MONEY_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_toggle_section_keyboard(
|
||||||
|
titles: dict[str, str],
|
||||||
|
state: dict[str, bool],
|
||||||
|
action: str,
|
||||||
|
columns: int,
|
||||||
|
back_action: str = "settings",
|
||||||
|
extra_rows: list[list[InlineKeyboardButton]] | None = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for index, key in enumerate(titles.keys(), start=1):
|
||||||
|
title = titles[key]
|
||||||
|
current_state = bool(state.get(key, False))
|
||||||
|
prefix = "✅" if current_state else "❌"
|
||||||
|
builder.button(
|
||||||
|
text=f"{prefix} {title}",
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action=action,
|
||||||
|
page=index,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(columns)
|
||||||
|
|
||||||
|
if extra_rows:
|
||||||
|
for row in extra_rows:
|
||||||
|
builder.row(*row)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=AdminPanelCallback(action=back_action).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="Кассы",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_cashboxes").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="Деньги",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="Кнопки",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_buttons").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="Уведомления",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="Режимы",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_modes").pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2, 2, 1)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_buttons_kb(buttons_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||||
|
return build_toggle_section_keyboard(
|
||||||
|
titles=BUTTON_TITLES,
|
||||||
|
state=buttons_state,
|
||||||
|
action="settings_button_toggle",
|
||||||
|
columns=2,
|
||||||
|
back_action="settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_cashboxes_kb(providers_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||||
|
return build_toggle_section_keyboard(
|
||||||
|
titles=PAYMENT_PROVIDER_TITLES,
|
||||||
|
state=providers_state,
|
||||||
|
action="settings_cashbox_toggle",
|
||||||
|
columns=2,
|
||||||
|
back_action="settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_notifications_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||||
|
intervals_button = InlineKeyboardButton(
|
||||||
|
text="Интервалы",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_notifications_intervals").pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_toggle_section_keyboard(
|
||||||
|
titles=NOTIFICATION_TITLES,
|
||||||
|
state={k: bool(notifications_state.get(k, False)) for k in NOTIFICATION_TITLES},
|
||||||
|
action="settings_notification_toggle",
|
||||||
|
columns=1,
|
||||||
|
back_action="settings",
|
||||||
|
extra_rows=[[intervals_button]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_notifications_intervals_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
keys = list(NOTIFICATION_TIME_FIELDS.keys())
|
||||||
|
for index, key in enumerate(keys, start=1):
|
||||||
|
title = NOTIFICATION_TIME_FIELDS[key]
|
||||||
|
value = notifications_state.get(key)
|
||||||
|
value_text = "не задано" if value is None else str(value)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text=f"{title}: {value_text}",
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action="settings_notification_interval_edit",
|
||||||
|
page=index,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_modes_kb(modes_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||||
|
return build_toggle_section_keyboard(
|
||||||
|
titles=MODES_TITLES,
|
||||||
|
state=modes_state,
|
||||||
|
action="settings_modes_toggle",
|
||||||
|
columns=2,
|
||||||
|
back_action="settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_money_kb(money_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
field_keys = list(MONEY_FIELDS.keys())
|
||||||
|
for index, key in enumerate(field_keys, start=1):
|
||||||
|
title = MONEY_FIELDS[key]
|
||||||
|
value = money_state.get(key)
|
||||||
|
|
||||||
|
if key == "RUB_TO_USD":
|
||||||
|
if value is False or value is None:
|
||||||
|
value_text = "по ЦБ РФ"
|
||||||
|
else:
|
||||||
|
value_text = str(value)
|
||||||
|
elif key == "CASHBACK":
|
||||||
|
try:
|
||||||
|
numeric_value = float(value) if value not in (None, False) else 0.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
numeric_value = 0.0
|
||||||
|
if numeric_value <= 0:
|
||||||
|
value_text = "выкл"
|
||||||
|
else:
|
||||||
|
value_text = f"{numeric_value:g} %"
|
||||||
|
else:
|
||||||
|
value_text = "не задано" if value is None else str(value)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text=f"{title}: {value_text}",
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action="settings_money_edit",
|
||||||
|
page=index,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = str(money_state.get("CURRENCY_MODE") or "RUB").upper()
|
||||||
|
if mode not in ("RUB", "USD", "RUB+USD"):
|
||||||
|
mode = "RUB"
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text=f"Режим валют: {mode}",
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action="settings_money_currency",
|
||||||
|
page=0,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import BUTTONS_CONFIG, update_buttons_config
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import BUTTON_TITLES, build_settings_buttons_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings_buttons")
|
||||||
|
router.callback_query.filter(IsAdminFilter())
|
||||||
|
|
||||||
|
|
||||||
|
async def load_button_settings() -> dict[str, bool]:
|
||||||
|
config = BUTTONS_CONFIG or {}
|
||||||
|
return {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_buttons"))
|
||||||
|
async def open_settings_buttons_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||||
|
buttons_state = await load_button_settings()
|
||||||
|
text = "Здесь вы можете включать или отключать кнопки в меню бота."
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_settings_buttons_kb(buttons_state))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_button_toggle"))
|
||||||
|
async def toggle_button_setting(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
keys = list(BUTTON_TITLES.keys())
|
||||||
|
idx = callback_data.page
|
||||||
|
|
||||||
|
if not 1 <= idx <= len(keys):
|
||||||
|
await callback.answer("Неизвестная настройка", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = keys[idx - 1]
|
||||||
|
|
||||||
|
config = dict(BUTTONS_CONFIG or {})
|
||||||
|
current = bool(config.get(key, False))
|
||||||
|
config[key] = not current
|
||||||
|
|
||||||
|
await update_buttons_config(session, config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
buttons_state = {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=build_settings_buttons_kb(buttons_state))
|
||||||
|
await callback.answer("Настройка обновлена")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import PAYMENTS_CONFIG, update_payments_config
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import PAYMENT_PROVIDER_TITLES, build_settings_cashboxes_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings_cashboxes")
|
||||||
|
router.callback_query.filter(IsAdminFilter())
|
||||||
|
|
||||||
|
|
||||||
|
async def load_payment_providers_settings() -> dict[str, bool]:
|
||||||
|
config = PAYMENTS_CONFIG or {}
|
||||||
|
return {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashboxes"))
|
||||||
|
async def open_settings_cashboxes_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||||
|
providers_state = await load_payment_providers_settings()
|
||||||
|
text = "Здесь можно включать и отключать платёжные провайдеры."
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_settings_cashboxes_kb(providers_state))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashbox_toggle"))
|
||||||
|
async def toggle_cashbox_setting(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
keys = list(PAYMENT_PROVIDER_TITLES.keys())
|
||||||
|
index = callback_data.page
|
||||||
|
|
||||||
|
if not 1 <= index <= len(keys):
|
||||||
|
await callback.answer("Неизвестная касса", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
provider_code = keys[index - 1]
|
||||||
|
|
||||||
|
config = dict(PAYMENTS_CONFIG or {})
|
||||||
|
current_value = bool(config.get(provider_code, False))
|
||||||
|
config[provider_code] = not current_value
|
||||||
|
|
||||||
|
await update_payments_config(
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated_state = {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
|
||||||
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=build_settings_cashboxes_kb(updated_state),
|
||||||
|
)
|
||||||
|
await callback.answer("Настройка обновлена")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from typing import Final
|
||||||
|
|
||||||
|
BUTTON_TITLES: Final[dict[str, str]] = {
|
||||||
|
"CHANNEL_BUTTON_ENABLE": "Канал",
|
||||||
|
"DONATIONS_BUTTON_ENABLE": "Донаты",
|
||||||
|
"BALANCE_BUTTON_ENABLE": "Баланс",
|
||||||
|
"REFERRAL_QR_BUTTON_ENABLE": "QR реф.меню",
|
||||||
|
"DELETE_KEY_BUTTON_ENABLE": "Удалить подп-ку",
|
||||||
|
"INSTRUCTIONS_BUTTON_ENABLE": "Инструкции",
|
||||||
|
"TOGGLE_CLIENT_BUTTON_ENABLE": "Заморозка подписки",
|
||||||
|
"GIFT_BUTTON_ENABLE": "Подарки",
|
||||||
|
"REFERRAL_BUTTON_ENABLE": "Реф.система",
|
||||||
|
"TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов",
|
||||||
|
"QRCODE_BUTTON_ENABLE": "QR подписки",
|
||||||
|
"HWID_RESET_BUTTON_ENABLE": "Сброс HWID",
|
||||||
|
}
|
||||||
|
|
||||||
|
NOTIFICATION_TITLES: Final[dict[str, str]] = {
|
||||||
|
"RENEW_ENABLED": "Авто-продление",
|
||||||
|
"EXPIRY_24H_ENABLED": "За 24 часа",
|
||||||
|
"EXPIRY_10H_ENABLED": "За 10 часов",
|
||||||
|
"DELETE_KEY_ENABLED": "Удалять просроченные",
|
||||||
|
"RENEW_EXPIRED_ENABLED": "Продлевать просроченные",
|
||||||
|
"HOT_LEADS_ENABLED": "Горячие лиды",
|
||||||
|
}
|
||||||
|
|
||||||
|
NOTIFICATION_TIME_FIELDS: Final[dict[str, str]] = {
|
||||||
|
"BASE_NOTIFICATION_MINUTE": "Проверка (сек)",
|
||||||
|
"INACTIVE_USER_ENABLED": "Неактивные (ч)",
|
||||||
|
"EXPIRY_24H_BEFORE_HOURS": "До 24ч (ч)",
|
||||||
|
"EXPIRY_10H_BEFORE_HOURS": "До 10ч (ч)",
|
||||||
|
"DELETE_KEY_DELAY_HOURS": "Удаление (ч)",
|
||||||
|
"EXTRA_DAYS_AFTER_EXPIRY": "Дни к пробнику",
|
||||||
|
"INACTIVE_TRAFFIC_ENABLED": "Трафик неакт. (ч)",
|
||||||
|
"HOT_LEADS_INTERVAL_HOURS": "Гор.лиды (ч)",
|
||||||
|
"DISCOUNT_ACTIVE_HOURS": "Скидка (ч)",
|
||||||
|
}
|
||||||
|
|
||||||
|
PAYMENT_PROVIDER_TITLES: Final[dict[str, str]] = {
|
||||||
|
"YOOKASSA": "YooKassa",
|
||||||
|
"YOOMONEY": "YooMoney",
|
||||||
|
"ROBOKASSA": "Robokassa",
|
||||||
|
"KASSAI_CARDS": "KassaAI карты",
|
||||||
|
"KASSAI_SBP": "KassaAI СБП",
|
||||||
|
"TRIBUTE": "Tribute",
|
||||||
|
"HELEKET": "Heleket",
|
||||||
|
"CRYPTOBOT": "CryptoBot",
|
||||||
|
"FREEKASSA": "FreeKassa",
|
||||||
|
"STARS": "Telegram Stars",
|
||||||
|
}
|
||||||
|
|
||||||
|
MODES_TITLES: Final[dict[str, str]] = {
|
||||||
|
"CAPTCHA_ENABLED": "Капча",
|
||||||
|
"CHANNEL_CHECK_ENABLED": "Обязат. канал",
|
||||||
|
"SHOW_START_MENU_ONLY_ONCE": "Старт один раз",
|
||||||
|
"INLINE_MODE_ENABLED": "Инлайн-режим",
|
||||||
|
"RANDOM_SUBSCRIPTIONS_ENABLED": "Случайные страны",
|
||||||
|
"COUNTRY_SELECTION_ENABLED": "Режим стран",
|
||||||
|
"REMNAWAVE_WEBAPP_ENABLED": "Remna WebApp",
|
||||||
|
"HAPP_CRYPTOLINK_ENABLED": "Happ-ссылки",
|
||||||
|
"LEGACY_LINKS_ENABLED": "Старые ссылки",
|
||||||
|
"DIRECT_START_DISABLED": "Тихий режим",
|
||||||
|
"TRIAL_TIME_DISABLED": "Отключить триал",
|
||||||
|
}
|
||||||
|
|
||||||
|
MONEY_FIELDS: Final[dict[str, str]] = {
|
||||||
|
"FX_MARKUP": "Наценка FX (%)",
|
||||||
|
"RUB_TO_USD": "Курс USD/RUB",
|
||||||
|
"CASHBACK": "Кэшбэк (%)",
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import build_settings_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings_manage")
|
||||||
|
router.callback_query.filter(IsAdminFilter())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings"))
|
||||||
|
async def open_settings_menu(callback: CallbackQuery) -> None:
|
||||||
|
text = (
|
||||||
|
"Здесь вы можете изменить основные настройки бота, не перезагружая его\n"
|
||||||
|
"(Меню будет пополняться)\n\n"
|
||||||
|
"<blockquote>"
|
||||||
|
"⚠️⚠️⚠️ ВАЖНО! Эти настройки являются техническими и не рассчитаны на обычное использование.\n"
|
||||||
|
"Не включайте и не меняйте настройки, если вы не понимаете, что они делают!\n"
|
||||||
|
"Бездумные изменения могут нарушить работу бота или базы данных."
|
||||||
|
"</blockquote>\n\n"
|
||||||
|
"Если вы не уверены, что делает настройка — уточните вопрос в чате."
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_settings_kb())
|
||||||
|
await callback.answer()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import MODES_CONFIG, update_modes_config
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import MODES_TITLES, build_settings_modes_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings_modes")
|
||||||
|
router.callback_query.filter(IsAdminFilter())
|
||||||
|
|
||||||
|
|
||||||
|
async def load_modes_settings() -> dict[str, bool]:
|
||||||
|
config = MODES_CONFIG or {}
|
||||||
|
return {k: bool(config.get(k, False)) for k in MODES_TITLES.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_modes"))
|
||||||
|
async def open_settings_modes_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||||
|
modes_state = await load_modes_settings()
|
||||||
|
text = "Здесь вы можете включать и отключать режимы работы бота."
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_settings_modes_kb(modes_state))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_modes_toggle"))
|
||||||
|
async def toggle_mode_setting(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
keys = list(MODES_TITLES.keys())
|
||||||
|
index = callback_data.page
|
||||||
|
|
||||||
|
if not 1 <= index <= len(keys):
|
||||||
|
await callback.answer("Неизвестная настройка", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = keys[index - 1]
|
||||||
|
|
||||||
|
config = dict(MODES_CONFIG or {})
|
||||||
|
current = bool(config.get(key, False))
|
||||||
|
config[key] = not current
|
||||||
|
|
||||||
|
await update_modes_config(session, config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
modes_state = {k: bool(config.get(k, False)) for k in MODES_TITLES.keys()}
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=build_settings_modes_kb(modes_state))
|
||||||
|
await callback.answer("Настройка обновлена")
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import MONEY_CONFIG, update_money_config
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import MONEY_FIELDS, build_settings_money_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class MoneySettingsState(StatesGroup):
|
||||||
|
waiting_value = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_money"))
|
||||||
|
async def open_settings_money(callback: CallbackQuery) -> None:
|
||||||
|
text = (
|
||||||
|
"Настройки денег и мультивалютности.\n\n"
|
||||||
|
"Здесь можно:\n"
|
||||||
|
"• выбрать режим валют: RUB, USD или RUB+USD;\n"
|
||||||
|
"• задать наценку на конвертацию (FX);\n"
|
||||||
|
"• задать фиксированный курс USD/RUB или использовать курс ЦБ РФ;\n"
|
||||||
|
"• включить кэшбэк и задать процент от платежа.\n\n"
|
||||||
|
"Кэшбэк считается, если указан положительный процент."
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_settings_money_kb(MONEY_CONFIG),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_money_edit"))
|
||||||
|
async def edit_money_field_start(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
index = callback_data.page
|
||||||
|
keys = list(MONEY_FIELDS.keys())
|
||||||
|
|
||||||
|
if index is None or index < 1 or index > len(keys):
|
||||||
|
await callback.answer("Некорректное поле", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = keys[index - 1]
|
||||||
|
await state.set_state(MoneySettingsState.waiting_value)
|
||||||
|
await state.update_data(money_field_key=key)
|
||||||
|
|
||||||
|
if key == "FX_MARKUP":
|
||||||
|
text = "Введите наценку на валютные операции в процентах (например 0, 3.5, 10):"
|
||||||
|
elif key == "RUB_TO_USD":
|
||||||
|
text = "Введите курс USD/RUB (число, например 100).\nУкажите 0, чтобы использовать курс ЦБ РФ."
|
||||||
|
elif key == "CASHBACK":
|
||||||
|
text = "Введите размер кэшбэка в процентах (например 0, 5, 10).\n0 или отрицательное значение выключит кэшбэк."
|
||||||
|
else:
|
||||||
|
text = "Введите новое значение:"
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Отмена",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money").pack(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=keyboard)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(MoneySettingsState.waiting_value)
|
||||||
|
async def edit_money_field_save(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
data = await state.get_data()
|
||||||
|
key = data.get("money_field_key")
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
"Ошибка состояния, попробуйте ещё раз.",
|
||||||
|
reply_markup=build_settings_money_kb(MONEY_CONFIG),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = (message.text or "").strip().replace(",", ".")
|
||||||
|
|
||||||
|
if key in ("FX_MARKUP", "CASHBACK"):
|
||||||
|
try:
|
||||||
|
value_float = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("Некорректное число. Введите, например, 0, 3.5 или 10.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == "CASHBACK" and value_float <= 0:
|
||||||
|
value = False
|
||||||
|
else:
|
||||||
|
value = value_float
|
||||||
|
elif key == "RUB_TO_USD":
|
||||||
|
try:
|
||||||
|
value_float = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("Некорректное число. Введите, например, 90 или 100.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if value_float <= 0:
|
||||||
|
value = False
|
||||||
|
else:
|
||||||
|
value = value_float
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
|
||||||
|
money_config = MONEY_CONFIG.copy()
|
||||||
|
money_config[key] = value
|
||||||
|
|
||||||
|
await update_money_config(session, money_config)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"Настройки денег обновлены.",
|
||||||
|
reply_markup=build_settings_money_kb(MONEY_CONFIG),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_money_currency"))
|
||||||
|
async def open_currency_mode_menu(callback: CallbackQuery) -> None:
|
||||||
|
money_config = MONEY_CONFIG
|
||||||
|
mode = str(money_config.get("CURRENCY_MODE") or "RUB").upper()
|
||||||
|
|
||||||
|
if mode not in ("RUB", "USD", "RUB+USD"):
|
||||||
|
mode = "RUB"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"Выберите режим валют:\n\n"
|
||||||
|
"• RUB — все цены только в рублях;\n"
|
||||||
|
"• USD — все цены только в долларах;\n"
|
||||||
|
"• RUB+USD — мультивалюта, оба варианта."
|
||||||
|
)
|
||||||
|
|
||||||
|
rub_checked = mode == "RUB"
|
||||||
|
usd_checked = mode == "USD"
|
||||||
|
multi_checked = mode == "RUB+USD"
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=("✅ RUB" if rub_checked else "RUB"),
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money_currency_set", page=1).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=("✅ USD" if usd_checked else "USD"),
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money_currency_set", page=2).pack(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=("✅ RUB+USD" if multi_checked else "RUB+USD"),
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money_currency_set", page=3).pack(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=AdminPanelCallback(action="settings_money").pack(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=keyboard)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_money_currency_set"))
|
||||||
|
async def set_currency_mode(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
mode_index = callback_data.page
|
||||||
|
|
||||||
|
if mode_index not in (1, 2, 3):
|
||||||
|
await callback.answer("Некорректный выбор", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode_index == 1:
|
||||||
|
new_mode = "RUB"
|
||||||
|
elif mode_index == 2:
|
||||||
|
new_mode = "USD"
|
||||||
|
else:
|
||||||
|
new_mode = "RUB+USD"
|
||||||
|
|
||||||
|
money_config = MONEY_CONFIG.copy()
|
||||||
|
money_config["CURRENCY_MODE"] = new_mode
|
||||||
|
|
||||||
|
await update_money_config(session, money_config)
|
||||||
|
|
||||||
|
await open_currency_mode_menu(callback)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.bootstrap import NOTIFICATIONS_CONFIG, update_notifications_config
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import (
|
||||||
|
NOTIFICATION_TIME_FIELDS,
|
||||||
|
NOTIFICATION_TITLES,
|
||||||
|
build_settings_notifications_intervals_kb,
|
||||||
|
build_settings_notifications_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name="admin_settings_notifications")
|
||||||
|
router.callback_query.filter(IsAdminFilter())
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationIntervalEditState(StatesGroup):
|
||||||
|
waiting_for_value = State()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_notification_settings() -> dict[str, object]:
|
||||||
|
return dict(NOTIFICATIONS_CONFIG or {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_notifications"))
|
||||||
|
async def open_settings_notifications_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||||
|
notifications_state = await load_notification_settings()
|
||||||
|
text = "Настройки уведомлений: включение и выключение."
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_settings_notifications_kb(notifications_state))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_notifications_intervals"))
|
||||||
|
async def open_settings_notifications_intervals_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||||
|
notifications_state = await load_notification_settings()
|
||||||
|
text = "Настройки интервалов и задержек уведомлений."
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_settings_notifications_intervals_kb(notifications_state),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_notification_toggle"))
|
||||||
|
async def toggle_notification_setting(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
keys = list(NOTIFICATION_TITLES.keys())
|
||||||
|
idx = callback_data.page
|
||||||
|
|
||||||
|
if not 1 <= idx <= len(keys):
|
||||||
|
await callback.answer("Неизвестная настройка", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = keys[idx - 1]
|
||||||
|
|
||||||
|
config = dict(NOTIFICATIONS_CONFIG or {})
|
||||||
|
current = bool(config.get(key, False))
|
||||||
|
config[key] = not current
|
||||||
|
|
||||||
|
await update_notifications_config(session, config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
notifications_state = await load_notification_settings()
|
||||||
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=build_settings_notifications_kb(notifications_state),
|
||||||
|
)
|
||||||
|
await callback.answer("Настройка обновлена")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_notification_interval_edit"))
|
||||||
|
async def edit_notification_interval_setting(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminPanelCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
keys = list(NOTIFICATION_TIME_FIELDS.keys())
|
||||||
|
idx = callback_data.page
|
||||||
|
|
||||||
|
if not 1 <= idx <= len(keys):
|
||||||
|
await callback.answer("Неизвестная настройка", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = keys[idx - 1]
|
||||||
|
title = NOTIFICATION_TIME_FIELDS[key]
|
||||||
|
current_value = (NOTIFICATIONS_CONFIG or {}).get(key)
|
||||||
|
|
||||||
|
await state.set_state(NotificationIntervalEditState.waiting_for_value)
|
||||||
|
await state.update_data(setting_key=key)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f'Введите новое значение для "{title}" (целое число).\n'
|
||||||
|
f"Текущее значение: {current_value if current_value is not None else 'не задано'}"
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(text=text)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(NotificationIntervalEditState.waiting_for_value)
|
||||||
|
async def notification_interval_value_input(message: Message, state: FSMContext, session: AsyncSession) -> None:
|
||||||
|
text_value = (message.text or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_value = int(text_value)
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("Введите целое число.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
key = data.get("setting_key")
|
||||||
|
if not key:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("Ошибка состояния. Попробуйте снова.")
|
||||||
|
return
|
||||||
|
|
||||||
|
config = dict(NOTIFICATIONS_CONFIG or {})
|
||||||
|
config[key] = new_value
|
||||||
|
|
||||||
|
await update_notifications_config(session, config)
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
notifications_state = await load_notification_settings()
|
||||||
|
await message.answer(
|
||||||
|
"Интервал обновлён.",
|
||||||
|
reply_markup=build_settings_notifications_intervals_kb(notifications_state),
|
||||||
|
)
|
||||||
@@ -144,29 +144,26 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
|||||||
for name, count in sorted_buckets:
|
for name, count in sorted_buckets:
|
||||||
tariff_stats_text += f"├ {name}: <b>{count}</b>\n"
|
tariff_stats_text += f"├ {name}: <b>{count}</b>\n"
|
||||||
|
|
||||||
for group_idx, (group, subgroups_dict) in enumerate(grouped_tariffs.items()):
|
for _group_idx, (group, subgroups_dict) in enumerate(grouped_tariffs.items()):
|
||||||
group_total = 0
|
group_total = 0
|
||||||
for tariffs_list in subgroups_dict.values():
|
for tariffs_list in subgroups_dict.values():
|
||||||
group_total += sum(count for _, count in tariffs_list)
|
group_total += sum(count for _, count in tariffs_list)
|
||||||
|
|
||||||
tariff_stats_text += f"Тариф <b>{group}</b> (<b>{group_total}</b>)\n"
|
tariff_stats_text += f"Тариф <b>{group}</b> (<b>{group_total}</b>)\n"
|
||||||
sorted_subgroups = sorted(
|
sorted_subgroups = sorted(subgroups_dict.items(), key=lambda x: (x[0] is None, x[0] or ""))
|
||||||
subgroups_dict.items(),
|
|
||||||
key=lambda x: (x[0] is None, x[0] or "")
|
|
||||||
)
|
|
||||||
for subgroup_idx, (subgroup, tariffs) in enumerate(sorted_subgroups):
|
for subgroup_idx, (subgroup, tariffs) in enumerate(sorted_subgroups):
|
||||||
sorted_tariffs = sorted(tariffs, key=lambda x: tariff_durations.get(x[0], 0))
|
sorted_tariffs = sorted(tariffs, key=lambda x: tariff_durations.get(x[0], 0))
|
||||||
subgroup_total = sum(count for _, count in sorted_tariffs)
|
subgroup_total = sum(count for _, count in sorted_tariffs)
|
||||||
is_last_subgroup = subgroup_idx == len(sorted_subgroups) - 1
|
is_last_subgroup = subgroup_idx == len(sorted_subgroups) - 1
|
||||||
|
|
||||||
if subgroup:
|
if subgroup:
|
||||||
prefix = "└─" if is_last_subgroup else "├─"
|
prefix = "└─" if is_last_subgroup else "├─"
|
||||||
tariff_stats_text += f" {prefix} Подгруппа: <b>{subgroup}</b> (<b>{subgroup_total}</b>)\n"
|
tariff_stats_text += f" {prefix} Подгруппа: <b>{subgroup}</b> (<b>{subgroup_total}</b>)\n"
|
||||||
|
|
||||||
for tariff_idx, (tid, count) in enumerate(sorted_tariffs):
|
for tariff_idx, (tid, count) in enumerate(sorted_tariffs):
|
||||||
name = tariff_names.get(tid, f"ID {tid}")
|
name = tariff_names.get(tid, f"ID {tid}")
|
||||||
is_last_tariff = tariff_idx == len(sorted_tariffs) - 1
|
is_last_tariff = tariff_idx == len(sorted_tariffs) - 1
|
||||||
|
|
||||||
if subgroup:
|
if subgroup:
|
||||||
if is_last_tariff and is_last_subgroup:
|
if is_last_tariff and is_last_subgroup:
|
||||||
prefix = " └─"
|
prefix = " └─"
|
||||||
@@ -328,7 +325,7 @@ async def send_daily_stats_report(session: AsyncSession):
|
|||||||
f"🗓️ <b>Сводка за {report_date.strftime('%d.%m.%Y')} с 00:00 до 23:59 МСК</b>\n\n"
|
f"🗓️ <b>Сводка за {report_date.strftime('%d.%m.%Y')} с 00:00 до 23:59 МСК</b>\n\n"
|
||||||
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
|
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
|
||||||
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
|
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
|
||||||
f"🔐 Активных ключей: <b>{active_keys}</b>\n\n"
|
f"🔐 Активных подписок: <b>{active_keys}</b>\n\n"
|
||||||
f"⏱️ <i>Отчёт сгенерирован: {update_time} МСК</i>"
|
f"⏱️ <i>Отчёт сгенерирован: {update_time} МСК</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
__all__ = ("router",)
|
from aiogram import Router
|
||||||
|
|
||||||
from .tariffs_handler import router
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
tariff_manage, # noqa: F401
|
||||||
|
tariff_sorting, # noqa: F401
|
||||||
|
tariff_subgroups, # noqa: F401
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
from sqlalchemy import delete, distinct, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import create_tariff
|
||||||
|
from database.models import Gift, Key, Server, Tariff
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from . import router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminTariffCallback,
|
||||||
|
build_cancel_kb,
|
||||||
|
build_edit_tariff_fields_kb,
|
||||||
|
build_tariff_groups_kb,
|
||||||
|
build_tariff_list_kb,
|
||||||
|
build_tariff_menu_kb,
|
||||||
|
)
|
||||||
|
from .tariff_states import TariffCreateState, TariffEditState
|
||||||
|
from .tariff_utils import render_tariff_card, validate_tariff_name
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter())
|
||||||
|
async def handle_tariff_menu(callback_query: CallbackQuery):
|
||||||
|
text = (
|
||||||
|
"<b>💸 Управление тарифами</b>\n\n"
|
||||||
|
"Вы можете выполнить следующие действия:\n\n"
|
||||||
|
"<b>🆕 Создать тариф</b>\n"
|
||||||
|
"<blockquote>• Установите длительность (в днях)\n"
|
||||||
|
"• Задайте цену (в рублях)\n"
|
||||||
|
"• Задайте лимит устройств (hwid/ip_limit)\n"
|
||||||
|
"• Укажите лимит трафика (в ГБ)</blockquote>\n\n"
|
||||||
|
"<b>📋 Редактировать тарифы</b>\n"
|
||||||
|
"<blockquote>• Просматривайте список текущих тарифов\n"
|
||||||
|
"• Изменяйте параметры или удаляйте при необходимости</blockquote>"
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action == "create"), IsAdminFilter())
|
||||||
|
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(TariffCreateState.group)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
|
||||||
|
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>\n\n"
|
||||||
|
"<b>Специальные группы:</b>\n"
|
||||||
|
"• <code>discounts</code> — тарифы со скидкой\n"
|
||||||
|
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
|
||||||
|
"• <code>gifts</code> — тарифы для подарков\n"
|
||||||
|
"• <code>trial</code> — тариф для пробного периода",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.group, IsAdminFilter())
|
||||||
|
async def process_tariff_group(message: Message, state: FSMContext):
|
||||||
|
group_code = message.text.strip().lower()
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[a-z0-9_-]+", group_code):
|
||||||
|
await message.answer(
|
||||||
|
"❌ Код группы должен содержать только латинские буквы, цифры, дефисы и подчёркивания.\n\nПовторите ввод:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(group_code=group_code)
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await message.answer(
|
||||||
|
"📝 Введите <b>название тарифа</b>\n\n"
|
||||||
|
"Например: <i>30 дней</i> или <i>1 месяц</i>\n\n"
|
||||||
|
"<i>Это название будет отображаться пользователю при выборе тарифа</i>",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.name, IsAdminFilter())
|
||||||
|
async def process_tariff_name(message: Message, state: FSMContext):
|
||||||
|
name = message.text.strip()
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_tariff_name(name)
|
||||||
|
if not is_valid:
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(name=name)
|
||||||
|
await state.set_state(TariffCreateState.duration)
|
||||||
|
await message.answer(
|
||||||
|
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.duration, IsAdminFilter())
|
||||||
|
async def process_tariff_duration(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
days = int(message.text.strip())
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректное количество дней (целое число больше 0):")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(duration_days=days)
|
||||||
|
await state.set_state(TariffCreateState.price)
|
||||||
|
await message.answer(
|
||||||
|
"💰 Введите <b>цену тарифа в рублях</b> (например: <i>150</i>)\n\n"
|
||||||
|
"<i>Будет показано клиенту при выборе тарифа</i>",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.price, IsAdminFilter())
|
||||||
|
async def process_tariff_price(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
price = int(message.text.strip())
|
||||||
|
if price < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(price_rub=price)
|
||||||
|
await state.set_state(TariffCreateState.traffic)
|
||||||
|
await message.answer(
|
||||||
|
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.traffic, IsAdminFilter())
|
||||||
|
async def process_tariff_traffic(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
traffic = int(message.text.strip())
|
||||||
|
if traffic < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректный лимит трафика (целое число 0 или больше):")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
|
||||||
|
await state.set_state(TariffCreateState.device_limit)
|
||||||
|
await message.answer(
|
||||||
|
"📱 Введите <b>лимит устройств (HWID)</b> для тарифа (например: <i>3</i>, 0 — безлимит):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.device_limit, IsAdminFilter())
|
||||||
|
async def process_tariff_device_limit(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
device_limit = int(message.text.strip())
|
||||||
|
if device_limit < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректный лимит устройств (целое число 0 или больше):")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(device_limit=device_limit if device_limit > 0 else None)
|
||||||
|
await state.set_state(TariffCreateState.vless)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"🔗 Этот тариф для выдачи VLESS (конфигурация для роутера)?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data="create_vless|1"),
|
||||||
|
InlineKeyboardButton(text="❌ Нет", callback_data="create_vless|0"),
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_tariff_creation")],
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("create_vless|"), TariffCreateState.vless, IsAdminFilter())
|
||||||
|
async def select_vless_creation(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, flag = callback.data.split("|", 1)
|
||||||
|
vless_flag = flag == "1"
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
|
||||||
|
new_tariff = await create_tariff(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"name": data["name"],
|
||||||
|
"group_code": data["group_code"],
|
||||||
|
"duration_days": data["duration_days"],
|
||||||
|
"price_rub": data["price_rub"],
|
||||||
|
"traffic_limit": data["traffic_limit"],
|
||||||
|
"device_limit": data.get("device_limit"),
|
||||||
|
"vless": vless_flag,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(TariffCreateState.confirm_more)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
|
||||||
|
"➕ Хотите добавить ещё один тариф в эту группу?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
|
||||||
|
InlineKeyboardButton(text="❌ Нет", callback_data="done_tariff_group"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
|
||||||
|
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await callback.message.edit_text("📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
|
||||||
|
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text("✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
|
||||||
|
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text("❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
|
||||||
|
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(
|
||||||
|
select(distinct(Tariff.group_code)).where(Tariff.group_code.isnot(None)).order_by(Tariff.group_code)
|
||||||
|
)
|
||||||
|
groups = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
await callback.message.edit_text("❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb())
|
||||||
|
return
|
||||||
|
|
||||||
|
special_groups = {
|
||||||
|
"discounts": "🔻 Скидки",
|
||||||
|
"discounts_max": "🔻 Макс. скидки",
|
||||||
|
"gifts": "🎁 Подарки",
|
||||||
|
"trial": "🚀 Пробный период",
|
||||||
|
}
|
||||||
|
|
||||||
|
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
|
||||||
|
text += "<b>Специальные группы:</b>\n"
|
||||||
|
for code, label in special_groups.items():
|
||||||
|
status = "✅ создана" if code in groups else "❌ не создана"
|
||||||
|
text += f"{label} — <code>{code}</code> — <b>{status}</b>\n"
|
||||||
|
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
|
||||||
|
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
from database.tariffs import get_tariffs
|
||||||
|
|
||||||
|
from .tariff_utils import tariff_to_dict
|
||||||
|
|
||||||
|
group_code = callback_data.action.split("|")[1]
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session, group_code=group_code)
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>📦 Тарифы группы: {group_code}</b>",
|
||||||
|
reply_markup=build_tariff_list_kb(tariff_dicts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter())
|
||||||
|
async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
|
||||||
|
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = tariff.group_code
|
||||||
|
|
||||||
|
if group_code == "gifts":
|
||||||
|
gift_check = await session.execute(select(Gift).where(Gift.tariff_id == tariff_id).limit(1))
|
||||||
|
if gift_check.scalar_one_or_none():
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.group_code == "gifts", Tariff.id != tariff_id))
|
||||||
|
other_tariffs = result.scalars().all()
|
||||||
|
|
||||||
|
if not other_tariffs:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Нельзя удалить тариф — он используется в подарках, а других тарифов в группе 'gifts' нет.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{t.name} — {t.price_rub}₽",
|
||||||
|
callback_data=f"confirm_delete_tariff_with_replace|{tariff_id}|{t.id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for t in other_tariffs
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>Этот тариф используется в подарках.</b>\n\n"
|
||||||
|
"Выберите тариф, на который заменить его во всех подарках перед удалением:",
|
||||||
|
reply_markup=builder,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("confirm_delete_tariff_with_replace|"), IsAdminFilter())
|
||||||
|
async def delete_tariff_with_gift_replacement(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
_, tariff_id_str, replacement_id_str = callback.data.split("|")
|
||||||
|
tariff_id = int(tariff_id_str)
|
||||||
|
replacement_id = int(replacement_id_str)
|
||||||
|
|
||||||
|
await session.execute(update(Gift).where(Gift.tariff_id == tariff_id).values(tariff_id=replacement_id))
|
||||||
|
await session.execute(update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None))
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = tariff.group_code
|
||||||
|
|
||||||
|
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code))
|
||||||
|
remaining_tariffs = result.scalars().all()
|
||||||
|
if not remaining_tariffs:
|
||||||
|
await session.execute(update(Server).where(Server.tariff_group == group_code).values(tariff_group=None))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await callback.message.edit_text("🗑 Тариф удалён. Все подарки обновлены.", reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
|
||||||
|
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|", 1)[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = tariff.group_code
|
||||||
|
|
||||||
|
await session.execute(update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None))
|
||||||
|
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code))
|
||||||
|
remaining_tariffs = result.scalars().all()
|
||||||
|
|
||||||
|
if not remaining_tariffs:
|
||||||
|
await session.execute(update(Server).where(Server.tariff_group == group_code).values(tariff_group=None))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await callback.message.edit_text("🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter())
|
||||||
|
async def start_edit_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
await state.update_data(tariff_id=tariff_id)
|
||||||
|
await state.set_state(TariffEditState.choosing_field)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>✏️ Что вы хотите изменить?</b>",
|
||||||
|
reply_markup=build_edit_tariff_fields_kb(tariff_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
|
||||||
|
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
|
||||||
|
_, _tariff_id, field = callback.data.split("|")
|
||||||
|
await state.update_data(field=field)
|
||||||
|
await state.set_state(TariffEditState.editing_value)
|
||||||
|
|
||||||
|
if field == "vless":
|
||||||
|
data = await state.get_data()
|
||||||
|
tariff_id = int(data["tariff_id"])
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🔗 Установить флаг VLESS:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data=f"set_vless|{tariff_id}|1"),
|
||||||
|
InlineKeyboardButton(text="❌ Нет", callback_data=f"set_vless|{tariff_id}|0"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
field_names = {
|
||||||
|
"name": "название тарифа",
|
||||||
|
"duration_days": "длительность в днях",
|
||||||
|
"price_rub": "цену в рублях",
|
||||||
|
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
|
||||||
|
"device_limit": "лимит устройств (0 — безлимит)",
|
||||||
|
"vless": "VLESS (да/нет)",
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("set_vless|"), TariffEditState.editing_value, IsAdminFilter())
|
||||||
|
async def set_vless_flag(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, tariff_id_str, flag = callback.data.split("|", 2)
|
||||||
|
tariff_id = int(tariff_id_str)
|
||||||
|
vless_flag = flag == "1"
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
tariff.vless = vless_flag
|
||||||
|
tariff.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffEditState.editing_value, IsAdminFilter())
|
||||||
|
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
tariff_id = data["tariff_id"]
|
||||||
|
field = data["field"]
|
||||||
|
value = message.text.strip()
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await message.answer("❌ Тариф не найден.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if field == "name":
|
||||||
|
is_valid, error_msg = validate_tariff_name(value)
|
||||||
|
if not is_valid:
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
|
||||||
|
try:
|
||||||
|
num = int(value)
|
||||||
|
if num < 0:
|
||||||
|
raise ValueError
|
||||||
|
if field in ["traffic_limit", "device_limit"]:
|
||||||
|
value = num if num > 0 else None
|
||||||
|
else:
|
||||||
|
value = num
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректное число.")
|
||||||
|
return
|
||||||
|
|
||||||
|
setattr(tariff, field, value)
|
||||||
|
tariff.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await message.answer(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
|
||||||
|
async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariff.is_active = not tariff.is_active
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter())
|
||||||
|
async def start_tariff_creation_existing_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||||
|
):
|
||||||
|
group_code = callback_data.action.split("|", 1)[1]
|
||||||
|
await state.update_data(group_code=group_code)
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Tariff
|
||||||
|
from database.tariffs import (
|
||||||
|
get_tariffs,
|
||||||
|
move_tariff_down as db_move_tariff_down,
|
||||||
|
move_tariff_up as db_move_tariff_up,
|
||||||
|
)
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import (
|
||||||
|
AdminTariffCallback,
|
||||||
|
build_tariff_arrangement_groups_kb,
|
||||||
|
build_tariffs_arrangement_kb,
|
||||||
|
)
|
||||||
|
from .tariff_utils import render_tariff_card
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action == "arrange"), IsAdminFilter())
|
||||||
|
async def show_tariff_arrangement_menu(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.group_code).where(Tariff.group_code.isnot(None)).distinct().order_by(Tariff.group_code)
|
||||||
|
)
|
||||||
|
groups = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
await callback.message.edit_text("❌ Нет доступных групп тарифов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🔢 <b>Управление расположением тарифов</b>\n\n"
|
||||||
|
"📋 <b>Как это работает:</b>\n"
|
||||||
|
"• Тарифы отображаются в порядке их расположения\n"
|
||||||
|
"• Меньший номер = выше в списке\n"
|
||||||
|
"• Новые тарифы добавляются в конец списка\n"
|
||||||
|
"• ⬆️ поднимает тариф выше (номер уменьшается)\n"
|
||||||
|
"• ⬇️ опускает тариф ниже (номер увеличивается)\n"
|
||||||
|
"• Подгруппы сортируются по общей сумме тарифов внутри\n\n"
|
||||||
|
"Выберите группу для управления расположением:",
|
||||||
|
reply_markup=build_tariff_arrangement_groups_kb(groups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("arrange_group|")), IsAdminFilter())
|
||||||
|
async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
group_code = callback_data.action.split("|")[1]
|
||||||
|
|
||||||
|
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
|
||||||
|
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||||
|
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ В этой группе пока нет активных тарифов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
grouped_tariffs = defaultdict(list)
|
||||||
|
for t in tariffs:
|
||||||
|
grouped_tariffs[t.get("subgroup_title")].append(t)
|
||||||
|
|
||||||
|
sorted_subgroups = sorted(
|
||||||
|
[k for k in grouped_tariffs if k],
|
||||||
|
key=lambda x: (subgroup_weights.get(x, 999999), x),
|
||||||
|
)
|
||||||
|
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
now = datetime.now(moscow_tz)
|
||||||
|
current_time = now.strftime("%d.%m.%y %H:%M:%S МСК")
|
||||||
|
|
||||||
|
text = f"🔢 <b>Итоговая сортировка тарифов в группе: {group_code}</b>\n\n"
|
||||||
|
|
||||||
|
if grouped_tariffs.get(None):
|
||||||
|
text += "<b>📋 Основные тарифы:</b>\n"
|
||||||
|
for t in grouped_tariffs[None]:
|
||||||
|
sort_order = t.get("sort_order", 1)
|
||||||
|
text += f"• {t.get('name')} <code>[позиция: {sort_order}]</code>\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
if sorted_subgroups:
|
||||||
|
text += "<b>📁 Подгруппы:</b>\n"
|
||||||
|
for subgroup in sorted_subgroups:
|
||||||
|
subgroup_weight = subgroup_weights.get(subgroup, 999999)
|
||||||
|
text += f"• <b>{subgroup}</b> <code>[вес группы: {subgroup_weight}]</code>\n"
|
||||||
|
for t in grouped_tariffs[subgroup]:
|
||||||
|
sort_order = t.get("sort_order", 1)
|
||||||
|
text += f" └ {t.get('name')} <code>[позиция: {sort_order}]</code>\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
text += f"\n{current_time}"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=build_tariffs_arrangement_kb(group_code, tariffs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_up|")), IsAdminFilter())
|
||||||
|
async def move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
success = await db_move_tariff_up(session, tariff_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.answer("❌ Тариф не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
await callback.answer("✅ Тариф перемещен выше (-1)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_down|")), IsAdminFilter())
|
||||||
|
async def move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
|
||||||
|
success = await db_move_tariff_down(session, tariff_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.answer("❌ Тариф не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
await callback.answer("✅ Тариф перемещен ниже (+1)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_up|")), IsAdminFilter())
|
||||||
|
async def quick_move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
parts = callback_data.action.split("|")
|
||||||
|
tariff_id = int(parts[1])
|
||||||
|
group_code = parts[2]
|
||||||
|
|
||||||
|
success = await db_move_tariff_up(session, tariff_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.answer("✅ Тариф перемещен выше (-1)")
|
||||||
|
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
|
||||||
|
await show_tariffs_arrangement(callback, new_callback_data, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_down|")), IsAdminFilter())
|
||||||
|
async def quick_move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||||
|
parts = callback_data.action.split("|")
|
||||||
|
tariff_id = int(parts[1])
|
||||||
|
group_code = parts[2]
|
||||||
|
|
||||||
|
success = await db_move_tariff_down(session, tariff_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.answer("✅ Тариф перемещен ниже (+1)")
|
||||||
|
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
|
||||||
|
await show_tariffs_arrangement(callback, new_callback_data, session)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class TariffCreateState(StatesGroup):
|
||||||
|
group = State()
|
||||||
|
name = State()
|
||||||
|
duration = State()
|
||||||
|
price = State()
|
||||||
|
traffic = State()
|
||||||
|
confirm_more = State()
|
||||||
|
device_limit = State()
|
||||||
|
vless = State()
|
||||||
|
|
||||||
|
|
||||||
|
class TariffEditState(StatesGroup):
|
||||||
|
choosing_field = State()
|
||||||
|
editing_value = State()
|
||||||
|
|
||||||
|
|
||||||
|
class TariffSubgroupState(StatesGroup):
|
||||||
|
selecting_tariffs = State()
|
||||||
|
entering_subgroup_title = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SubgroupEditState(StatesGroup):
|
||||||
|
entering_new_title = State()
|
||||||
|
confirming_deletion = State()
|
||||||
|
editing_tariffs = State()
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Tariff
|
||||||
|
from database.tariffs import (
|
||||||
|
create_subgroup_hash,
|
||||||
|
find_subgroup_by_hash,
|
||||||
|
get_tariffs,
|
||||||
|
)
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
from .keyboard import AdminTariffCallback, build_tariff_menu_kb
|
||||||
|
from .tariff_states import SubgroupEditState, TariffSubgroupState
|
||||||
|
from .tariff_utils import tariff_to_dict, validate_subgroup_title
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("start_subgrouping|"), IsAdminFilter())
|
||||||
|
async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
group_code = callback.data.split("|", 1)[1]
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session, group_code=group_code)
|
||||||
|
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Нет доступных тарифов для группировки.\n\nВсе тарифы уже находятся в подгруппах.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(TariffSubgroupState.selecting_tariffs)
|
||||||
|
await state.update_data(group_code=group_code, selected_tariff_ids=[])
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for tariff in tariffs:
|
||||||
|
builder.row(InlineKeyboardButton(text=f"{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}"))
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Выберите тарифы, которые нужно объединить в подгруппу:", reply_markup=builder.as_markup()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("sub_select|"), TariffSubgroupState.selecting_tariffs, IsAdminFilter())
|
||||||
|
async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|")[1])
|
||||||
|
data = await state.get_data()
|
||||||
|
selected = set(data.get("selected_tariff_ids", []))
|
||||||
|
|
||||||
|
if tariff_id in selected:
|
||||||
|
selected.remove(tariff_id)
|
||||||
|
else:
|
||||||
|
selected.add(tariff_id)
|
||||||
|
|
||||||
|
await state.update_data(selected_tariff_ids=list(selected))
|
||||||
|
|
||||||
|
group_code = data["group_code"]
|
||||||
|
tariffs = await get_tariffs(session, group_code=group_code)
|
||||||
|
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for tariff in tariffs:
|
||||||
|
is_selected = tariff.get("id") in selected
|
||||||
|
prefix = "✅ " if is_selected else ""
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}")
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
F.data == "subgroup_continue",
|
||||||
|
TariffSubgroupState.selecting_tariffs,
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def ask_subgroup_title(callback: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
if not data.get("selected_tariff_ids"):
|
||||||
|
await callback.answer("Выберите хотя бы один тариф", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(TariffSubgroupState.entering_subgroup_title)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📁 Введите название новой подгруппы:",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffSubgroupState.entering_subgroup_title, IsAdminFilter())
|
||||||
|
async def apply_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
title = message.text.strip()
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_subgroup_title(title)
|
||||||
|
if not is_valid:
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
selected_ids = data.get("selected_tariff_ids", [])
|
||||||
|
|
||||||
|
if not selected_ids:
|
||||||
|
await message.answer("❌ Нет выбранных тарифов.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff).where(Tariff.id.in_(selected_ids)).values(subgroup_title=title, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ {len(selected_ids)} тарифов сгруппированы в подгруппу: <b>{title}</b>.",
|
||||||
|
reply_markup=build_tariff_menu_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "cancel_subgrouping", IsAdminFilter())
|
||||||
|
async def cancel_subgrouping(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text("❌ Группировка в подгруппу отменена.", reply_markup=build_tariff_menu_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("view_subgroup|"), IsAdminFilter())
|
||||||
|
async def view_subgroup_tariffs(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||||
|
|
||||||
|
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||||
|
|
||||||
|
if not subgroup_title:
|
||||||
|
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session, group_code=group_code)
|
||||||
|
tariffs = [t for t in tariffs if t.get("subgroup_title") == subgroup_title]
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariffs_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for t in tariffs_dicts:
|
||||||
|
title = f"{t['name']} — {t['price_rub']}₽"
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=title,
|
||||||
|
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📝 Переименовать подгруппу",
|
||||||
|
callback_data=f"rename_subgroup|{subgroup_hash}|{group_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✏️ Редактировать подгруппу",
|
||||||
|
callback_data=f"edit_subgroup_tariffs|{subgroup_hash}|{group_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑 Удалить подгруппу",
|
||||||
|
callback_data=f"delete_subgroup|{subgroup_hash}|{group_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>📂 Подгруппа: {subgroup_title}</b>",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("rename_subgroup|"), IsAdminFilter())
|
||||||
|
async def start_rename_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||||
|
|
||||||
|
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||||
|
|
||||||
|
if not subgroup_title:
|
||||||
|
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
subgroup_title=subgroup_title,
|
||||||
|
group_code=group_code,
|
||||||
|
subgroup_hash=subgroup_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(SubgroupEditState.entering_new_title)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"📝 Введите новое название подгруппы:\n<b>{subgroup_title}</b>\n\nИли нажмите Отмена.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(SubgroupEditState.entering_new_title, IsAdminFilter())
|
||||||
|
async def save_new_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
new_title = message.text.strip()
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_subgroup_title(new_title)
|
||||||
|
if not is_valid:
|
||||||
|
data = await state.get_data()
|
||||||
|
subgroup_hash = data.get("subgroup_hash")
|
||||||
|
group_code = data.get("group_code")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
old_title = data["subgroup_title"]
|
||||||
|
group_code = data["group_code"]
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff)
|
||||||
|
.where(
|
||||||
|
Tariff.group_code == group_code,
|
||||||
|
Tariff.subgroup_title == old_title,
|
||||||
|
)
|
||||||
|
.values(subgroup_title=new_title)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
create_subgroup_hash(new_title, group_code)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Подгруппа <b>{old_title}</b> переименована в <b>{new_title}</b>.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("delete_subgroup|"), IsAdminFilter())
|
||||||
|
async def confirm_delete_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||||
|
|
||||||
|
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||||
|
|
||||||
|
if not subgroup_title:
|
||||||
|
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
subgroup_title=subgroup_title,
|
||||||
|
group_code=group_code,
|
||||||
|
subgroup_hash=subgroup_hash,
|
||||||
|
)
|
||||||
|
await state.set_state(SubgroupEditState.confirming_deletion)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"❗ Вы уверены, что хотите <b>удалить</b> подгруппу <b>{subgroup_title}</b>?\n"
|
||||||
|
"Это удалит поле `subgroup_title` у всех связанных тарифов.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Удалить", callback_data="confirm_subgroup_deletion"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "confirm_subgroup_deletion", SubgroupEditState.confirming_deletion, IsAdminFilter())
|
||||||
|
async def perform_subgroup_deletion(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
subgroup_title = data["subgroup_title"]
|
||||||
|
group_code = data["group_code"]
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff)
|
||||||
|
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
|
||||||
|
.values(subgroup_title=None)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Подгруппа <b>{subgroup_title}</b> удалена.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_subgroup_tariffs|"), IsAdminFilter())
|
||||||
|
async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||||
|
|
||||||
|
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||||
|
|
||||||
|
if not subgroup_title:
|
||||||
|
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
|
||||||
|
all_tariffs_to_show = [
|
||||||
|
t
|
||||||
|
for t in all_tariffs_to_show
|
||||||
|
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
|
||||||
|
]
|
||||||
|
|
||||||
|
subgroup_tariff_ids = {t.get("id") for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title}
|
||||||
|
|
||||||
|
if not all_tariffs_to_show:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Нет доступных тарифов для редактирования.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(SubgroupEditState.editing_tariffs)
|
||||||
|
await state.update_data(
|
||||||
|
subgroup_title=subgroup_title,
|
||||||
|
group_code=group_code,
|
||||||
|
subgroup_hash=subgroup_hash,
|
||||||
|
selected_tariff_ids=list(subgroup_tariff_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for tariff in all_tariffs_to_show:
|
||||||
|
is_in_subgroup = tariff.get("id") in subgroup_tariff_ids
|
||||||
|
prefix = "✅ " if is_in_subgroup else ""
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ <b>Редактирование подгруппы: {subgroup_title}</b>\n\n"
|
||||||
|
"✅ - тарифы в подгруппе\n\n"
|
||||||
|
"Нажмите на тариф, чтобы добавить/убрать его:",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_sub_toggle|"), SubgroupEditState.editing_tariffs, IsAdminFilter())
|
||||||
|
async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|")[1])
|
||||||
|
data = await state.get_data()
|
||||||
|
selected_ids = set(data.get("selected_tariff_ids", []))
|
||||||
|
|
||||||
|
if tariff_id in selected_ids:
|
||||||
|
selected_ids.remove(tariff_id)
|
||||||
|
else:
|
||||||
|
selected_ids.add(tariff_id)
|
||||||
|
|
||||||
|
await state.update_data(selected_tariff_ids=list(selected_ids))
|
||||||
|
|
||||||
|
subgroup_title = data["subgroup_title"]
|
||||||
|
group_code = data["group_code"]
|
||||||
|
subgroup_hash = data["subgroup_hash"]
|
||||||
|
|
||||||
|
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
|
||||||
|
all_tariffs_to_show = [
|
||||||
|
t
|
||||||
|
for t in all_tariffs_to_show
|
||||||
|
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
|
||||||
|
]
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for tariff in all_tariffs_to_show:
|
||||||
|
is_selected = tariff.get("id") in selected_ids
|
||||||
|
prefix = "✅ " if is_selected else ""
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "edit_sub_save", SubgroupEditState.editing_tariffs, IsAdminFilter())
|
||||||
|
async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
subgroup_title = data["subgroup_title"]
|
||||||
|
group_code = data["group_code"]
|
||||||
|
subgroup_hash = data["subgroup_hash"]
|
||||||
|
selected_tariff_ids = set(data.get("selected_tariff_ids", []))
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff).where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
|
||||||
|
)
|
||||||
|
current_subgroup_tariffs = result.scalars().all()
|
||||||
|
current_tariff_ids = {t.id for t in current_subgroup_tariffs}
|
||||||
|
|
||||||
|
to_add = selected_tariff_ids - current_tariff_ids
|
||||||
|
to_remove = current_tariff_ids - selected_tariff_ids
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff).where(Tariff.id.in_(to_remove)).values(subgroup_title=None, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
|
||||||
|
if to_add:
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff)
|
||||||
|
.where(Tariff.id.in_(to_add))
|
||||||
|
.values(subgroup_title=subgroup_title, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
if not selected_tariff_ids:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Подгруппа <b>{subgroup_title}</b> была расформирована.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад к группе тарифов",
|
||||||
|
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
changes_text = []
|
||||||
|
if to_add:
|
||||||
|
added_names = []
|
||||||
|
for tariff_id in to_add:
|
||||||
|
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
|
||||||
|
name = result.scalar_one()
|
||||||
|
if name:
|
||||||
|
added_names.append(name)
|
||||||
|
changes_text.append(f"➕ Добавлено: {', '.join(added_names)}")
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
removed_names = []
|
||||||
|
for tariff_id in to_remove:
|
||||||
|
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
|
||||||
|
name = result.scalar_one()
|
||||||
|
if name:
|
||||||
|
removed_names.append(name)
|
||||||
|
changes_text.append(f"➖ Удалено: {', '.join(removed_names)}")
|
||||||
|
|
||||||
|
if not changes_text:
|
||||||
|
changes_text.append("Изменений не было")
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ <b>Подгруппа обновлена: {subgroup_title}</b>\n\n{chr(10).join(changes_text)}",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад к подгруппе", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
|
||||||
|
from database.models import Tariff
|
||||||
|
|
||||||
|
from .keyboard import build_single_tariff_kb
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TARIFF_NAME_LENGTH = 40
|
||||||
|
MAX_SUBGROUP_TITLE_LENGTH = 40
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tariff_name(name: str) -> tuple[bool, str]:
|
||||||
|
if len(name) > MAX_TARIFF_NAME_LENGTH:
|
||||||
|
return False, f"Название тарифа слишком длинное. Максимум {MAX_TARIFF_NAME_LENGTH} символов."
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_subgroup_title(title: str) -> tuple[bool, str]:
|
||||||
|
if len(title) > MAX_SUBGROUP_TITLE_LENGTH:
|
||||||
|
return False, f"Название подгруппы слишком длинное. Максимум {MAX_SUBGROUP_TITLE_LENGTH} символов."
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def tariff_to_dict(tariff) -> dict:
|
||||||
|
if isinstance(tariff, dict):
|
||||||
|
return tariff
|
||||||
|
return {
|
||||||
|
"id": tariff.id,
|
||||||
|
"name": tariff.name,
|
||||||
|
"price_rub": tariff.price_rub,
|
||||||
|
"group_code": tariff.group_code,
|
||||||
|
"subgroup_title": tariff.subgroup_title,
|
||||||
|
"sort_order": tariff.sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||||
|
traffic_text = f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
|
||||||
|
device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
|
||||||
|
sort_order = getattr(tariff, "sort_order", 1)
|
||||||
|
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
|
||||||
|
f"📁 Группа: <code>{tariff.group_code}</code>\n"
|
||||||
|
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
|
||||||
|
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
|
||||||
|
f"📦 Трафик: <b>{traffic_text}</b>\n"
|
||||||
|
f"📱 Устройств: <b>{device_text}</b>\n"
|
||||||
|
f"🔗 VLESS: <b>{vless_text}</b>\n"
|
||||||
|
f"🔢 Позиция: <b>{sort_order}</b>\n"
|
||||||
|
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return text, build_single_tariff_kb(tariff.id, tariff.group_code)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
|||||||
__all__ = ("router",)
|
from aiogram import Router
|
||||||
|
|
||||||
from .users_handler import router
|
from . import users_bans, users_hwid, users_keys, users_manage
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
router.include_router(users_manage.router)
|
||||||
|
router.include_router(users_hwid.router)
|
||||||
|
router.include_router(users_keys.router)
|
||||||
|
router.include_router(users_bans.router)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import ManualBan
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from .keyboard import AdminUserEditorCallback, build_editor_btn, build_editor_kb
|
||||||
|
from .users_states import BanUserStates
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_ban"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_user_ban(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await state.update_data(tg_id=callback_data.tg_id)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text="🚫 Выберите тип блокировки пользователя:",
|
||||||
|
reply_markup=build_editor_btn("🚫 Блокировки", tg_id=callback_data.tg_id, edit=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_ban_forever"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_ban_forever_start(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
|
||||||
|
await state.set_state(BanUserStates.waiting_for_forever_reason)
|
||||||
|
await state.update_data(tg_id=callback_data.tg_id)
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(build_editor_btn("⬅️ Назад", tg_id=callback_data.tg_id, edit=True))
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text="✏️ Введите причину <b>постоянной блокировки</b> (или <code>-</code>, чтобы пропустить):",
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BanUserStates.waiting_for_forever_reason, IsAdminFilter())
|
||||||
|
async def handle_ban_forever_reason_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
reason = message.text.strip()
|
||||||
|
if reason == "-":
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
tg_id = user_data.get("tg_id")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
pg_insert(ManualBan)
|
||||||
|
.values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
reason=reason,
|
||||||
|
banned_by=message.from_user.id,
|
||||||
|
until=None,
|
||||||
|
banned_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[ManualBan.tg_id],
|
||||||
|
set_={
|
||||||
|
"reason": reason,
|
||||||
|
"until": None,
|
||||||
|
"banned_by": message.from_user.id,
|
||||||
|
"banned_at": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=(f"✅ Пользователь <code>{tg_id}</code> забанен навсегда.{f'\n📄 Причина: {reason}' if reason else ''}"),
|
||||||
|
reply_markup=build_editor_kb(tg_id, edit=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_ban_temporary"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_ban_temporary(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
|
||||||
|
await state.set_state(BanUserStates.waiting_for_reason)
|
||||||
|
await state.update_data(tg_id=callback_data.tg_id)
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(build_editor_btn("⬅️ Назад", tg_id=callback_data.tg_id, edit=True))
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text="✏️ Введите причину <b>временной блокировки</b> (или <code>-</code>, чтобы пропустить):",
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BanUserStates.waiting_for_reason, IsAdminFilter())
|
||||||
|
async def handle_ban_reason_input(message: Message, state: FSMContext):
|
||||||
|
await state.update_data(reason=message.text.strip())
|
||||||
|
await state.set_state(BanUserStates.waiting_for_ban_duration)
|
||||||
|
|
||||||
|
user_data = await state.get_data()
|
||||||
|
tg_id = user_data.get("tg_id")
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(build_editor_btn("⬅️ Назад", tg_id=tg_id, edit=True))
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"⏳ Введите срок блокировки в днях (0 — навсегда):",
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BanUserStates.waiting_for_ban_duration, IsAdminFilter())
|
||||||
|
async def handle_ban_duration_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
user_data = await state.get_data()
|
||||||
|
tg_id = user_data.get("tg_id")
|
||||||
|
reason = user_data.get("reason")
|
||||||
|
if reason == "-":
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
days = int(message.text.strip())
|
||||||
|
if days < 1:
|
||||||
|
await message.answer("❗ Укажите срок минимум в 1 день.")
|
||||||
|
return
|
||||||
|
|
||||||
|
until = datetime.now(timezone.utc) + timedelta(days=days)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
pg_insert(ManualBan)
|
||||||
|
.values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
reason=reason,
|
||||||
|
banned_by=message.from_user.id,
|
||||||
|
until=until,
|
||||||
|
banned_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[ManualBan.tg_id],
|
||||||
|
set_={
|
||||||
|
"reason": reason,
|
||||||
|
"until": until,
|
||||||
|
"banned_at": datetime.now(timezone.utc),
|
||||||
|
"banned_by": message.from_user.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"✅ Пользователь <code>{tg_id}</code> временно забанен до <b>{until:%Y-%m-%d %H:%M}</b> по UTC."
|
||||||
|
f"{f'\n📄 Причина: {reason}' if reason else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text=text, reply_markup=build_editor_kb(tg_id, edit=True))
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❗ Введите корректное число дней.")
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_ban_shadow"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_ban_shadow(callback: CallbackQuery, callback_data: AdminUserEditorCallback, session: AsyncSession):
|
||||||
|
stmt = (
|
||||||
|
pg_insert(ManualBan)
|
||||||
|
.values(
|
||||||
|
tg_id=callback_data.tg_id,
|
||||||
|
reason="shadow",
|
||||||
|
banned_by=callback.from_user.id,
|
||||||
|
until=None,
|
||||||
|
banned_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[ManualBan.tg_id],
|
||||||
|
set_={
|
||||||
|
"reason": "shadow",
|
||||||
|
"until": None,
|
||||||
|
"banned_by": callback.from_user.id,
|
||||||
|
"banned_at": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=f"👻 Пользователь <code>{callback_data.tg_id}</code> получил теневой бан.",
|
||||||
|
reply_markup=build_editor_kb(callback_data.tg_id, edit=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_unban"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_user_unban(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
await session.execute(delete(ManualBan).where(ManualBan.tg_id == callback_data.tg_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"✅ Пользователь <code>{callback_data.tg_id}</code> разблокирован. Нажмите кнопку ниже для возврата в профиль."
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=build_editor_kb(callback_data.tg_id, edit=True))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||||
|
from database import get_client_id_by_email, get_servers
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from panels.remnawave import RemnawaveAPI
|
||||||
|
|
||||||
|
from .keyboard import AdminUserEditorCallback, build_editor_kb, build_hwid_menu_kb
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_hwid_menu"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_hwid_menu(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
email = callback_data.data
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
client_id = await get_client_id_by_email(session, email)
|
||||||
|
if not client_id:
|
||||||
|
await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.")
|
||||||
|
return
|
||||||
|
|
||||||
|
servers = await get_servers(session=session)
|
||||||
|
remna_server = None
|
||||||
|
for cluster_servers in servers.values():
|
||||||
|
for server in cluster_servers:
|
||||||
|
if server.get("panel_type", "") == "remnawave":
|
||||||
|
remna_server = server
|
||||||
|
break
|
||||||
|
if remna_server:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not remna_server:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"🚫 Нет доступного сервера Remnawave.",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
api = RemnawaveAPI(remna_server["api_url"])
|
||||||
|
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
await callback_query.message.edit_text("❌ Ошибка авторизации в Remnawave.")
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = await api.get_user_hwid_devices(client_id)
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
text = "💻 <b>HWID устройства</b>\n\n🔌 Нет привязанных устройств."
|
||||||
|
else:
|
||||||
|
text = f"💻 <b>HWID устройства</b>\n\nПривязано: <b>{len(devices)}</b>\n\n"
|
||||||
|
for idx, device in enumerate(devices, 1):
|
||||||
|
created = device.get("createdAt", "")[:19].replace("T", " ")
|
||||||
|
updated = device.get("updatedAt", "")[:19].replace("T", " ")
|
||||||
|
text += (
|
||||||
|
f"<b>{idx}.</b> <code>{device.get('hwid')}</code>\n"
|
||||||
|
f"└ 📱 <b>Модель:</b> {device.get('deviceModel') or '—'}\n"
|
||||||
|
f"└ 🧠 <b>Платформа:</b> {device.get('platform') or '—'} / {device.get('osVersion') or '—'}\n"
|
||||||
|
f"└ 🌐 <b>User-Agent:</b> {device.get('userAgent') or '—'}\n"
|
||||||
|
f"└ 🕓 <b>Создано:</b> {created}\n"
|
||||||
|
f"└ 🔄 <b>Обновлено:</b> {updated}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text, reply_markup=build_hwid_menu_kb(email, tg_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_hwid_reset"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_hwid_reset(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
email = callback_data.data
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
client_id = await get_client_id_by_email(session, email)
|
||||||
|
if not client_id:
|
||||||
|
await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.")
|
||||||
|
return
|
||||||
|
|
||||||
|
servers = await get_servers(session=session)
|
||||||
|
remna_server = None
|
||||||
|
for cluster_servers in servers.values():
|
||||||
|
for server in cluster_servers:
|
||||||
|
if server.get("panel_type", "") == "remnawave":
|
||||||
|
remna_server = server
|
||||||
|
break
|
||||||
|
if remna_server:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not remna_server:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"🚫 Нет доступного сервера Remnawave.",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
api = RemnawaveAPI(remna_server["api_url"])
|
||||||
|
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
await callback_query.message.edit_text("❌ Ошибка авторизации в Remnawave.")
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = await api.get_user_hwid_devices(client_id)
|
||||||
|
if not devices:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"ℹ️ У пользователя нет привязанных устройств.",
|
||||||
|
reply_markup=build_editor_kb(tg_id, True),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for device in devices:
|
||||||
|
if await api.delete_user_hwid_device(client_id, device["hwid"]):
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"✅ Удалено HWID-устройств: <b>{deleted}</b> из <b>{len(devices)}</b>.",
|
||||||
|
reply_markup=build_editor_kb(tg_id, True),
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
|||||||
|
import pytz
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
from aiogram.utils.formatting import BlockQuote, Bold, Text
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import (
|
||||||
|
get_balance,
|
||||||
|
get_key_details,
|
||||||
|
set_user_balance,
|
||||||
|
update_balance,
|
||||||
|
update_trial,
|
||||||
|
)
|
||||||
|
from database.models import Key, ManualBan, Payment, Referral, User
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.utils import sanitize_key_name
|
||||||
|
from utils.csv_export import export_referrals_csv
|
||||||
|
|
||||||
|
from ..panel.keyboard import (
|
||||||
|
AdminPanelCallback,
|
||||||
|
build_admin_back_btn,
|
||||||
|
build_admin_back_kb,
|
||||||
|
)
|
||||||
|
from .keyboard import (
|
||||||
|
AdminUserEditorCallback,
|
||||||
|
build_editor_kb,
|
||||||
|
build_user_edit_kb,
|
||||||
|
build_users_balance_change_kb,
|
||||||
|
build_users_balance_kb,
|
||||||
|
)
|
||||||
|
from .users_states import UserEditorState
|
||||||
|
|
||||||
|
|
||||||
|
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "search_user"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
text = (
|
||||||
|
"<b>🔍 Поиск пользователя</b>"
|
||||||
|
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
|
||||||
|
"\n\n🆔 ID - числовой айди"
|
||||||
|
"\n📝 Username - юзернейм пользователя"
|
||||||
|
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(UserEditorState.waiting_for_user_data)
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "search_key"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(UserEditorState.waiting_for_key_name)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🔑 Введите имя ключа для поиска:",
|
||||||
|
reply_markup=build_admin_back_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||||
|
async def handle_key_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
kb = build_admin_back_kb()
|
||||||
|
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||||
|
return
|
||||||
|
|
||||||
|
key_name = sanitize_key_name(message.text)
|
||||||
|
key_details = await get_key_details(session, key_name)
|
||||||
|
|
||||||
|
if not key_details:
|
||||||
|
await message.answer(
|
||||||
|
text="🚫 Пользователь с указанным именем ключа не найден.",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await process_user_search(message, state, session, key_details["tg_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
|
||||||
|
async def handle_user_data_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
kb = build_admin_back_kb()
|
||||||
|
|
||||||
|
if message.forward_from:
|
||||||
|
tg_id = message.forward_from.id
|
||||||
|
await process_user_search(message, state, session, tg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.text:
|
||||||
|
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.text.isdigit():
|
||||||
|
tg_id = int(message.text)
|
||||||
|
else:
|
||||||
|
username = message.text.strip().lstrip("@")
|
||||||
|
username = username.replace("https://t.me/", "")
|
||||||
|
|
||||||
|
stmt = select(User.tg_id).where(User.username == username)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
tg_id = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tg_id is None:
|
||||||
|
await message.answer(
|
||||||
|
text="🚫 Пользователь с указанным Username не найден!",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await process_user_search(message, state, session, tg_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_send_message"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_send_message(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=(
|
||||||
|
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю:\n\n"
|
||||||
|
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
|
||||||
|
"Вы можете отправить:\n"
|
||||||
|
"• Только <b>текст</b>\n"
|
||||||
|
"• Только <b>картинку</b>\n"
|
||||||
|
"• <b>Текст + картинку</b>"
|
||||||
|
),
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.update_data(tg_id=tg_id)
|
||||||
|
await state.set_state(UserEditorState.waiting_for_message_text)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
|
||||||
|
async def handle_message_text_input(message: Message, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
tg_id = data.get("tg_id")
|
||||||
|
text_message = message.html_text or message.text or message.caption or ""
|
||||||
|
photo = message.photo[-1].file_id if message.photo else None
|
||||||
|
|
||||||
|
max_len = 1024 if photo else 4096
|
||||||
|
if len(text_message) > max_len:
|
||||||
|
await message.answer(
|
||||||
|
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(text=text_message, photo=photo)
|
||||||
|
await state.set_state(UserEditorState.preview_message)
|
||||||
|
|
||||||
|
if photo:
|
||||||
|
await message.answer_photo(photo=photo, caption=text_message, parse_mode="HTML")
|
||||||
|
else:
|
||||||
|
await message.answer(text=text_message, parse_mode="HTML")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"👀 Это предпросмотр сообщения. Отправить?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="📤 Отправить", callback_data="send_user_message"),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_user_message"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
F.data == "send_user_message",
|
||||||
|
IsAdminFilter(),
|
||||||
|
UserEditorState.preview_message,
|
||||||
|
)
|
||||||
|
async def handle_send_user_message(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
tg_id = data.get("tg_id")
|
||||||
|
text_message = data.get("text")
|
||||||
|
photo = data.get("photo")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if photo:
|
||||||
|
await callback_query.bot.send_photo(
|
||||||
|
chat_id=tg_id,
|
||||||
|
photo=photo,
|
||||||
|
caption=text_message,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback_query.bot.send_message(
|
||||||
|
chat_id=tg_id,
|
||||||
|
text=text_message,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✅ Сообщение успешно отправлено.",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Не удалось отправить сообщение: {e}",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
F.data == "cancel_user_message",
|
||||||
|
IsAdminFilter(),
|
||||||
|
UserEditorState.preview_message,
|
||||||
|
)
|
||||||
|
async def handle_cancel_user_message(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
tg_id = data.get("tg_id")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🚫 Отправка сообщения отменена.",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_trial_restore(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
await update_trial(session, tg_id, 0)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✅ Триал успешно восстановлен!",
|
||||||
|
reply_markup=build_editor_kb(tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_balance_edit"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_balance_change(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Payment.amount, Payment.payment_system, Payment.status, Payment.created_at)
|
||||||
|
.where(Payment.tg_id == tg_id)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
records = result.all()
|
||||||
|
|
||||||
|
balance = await get_balance(session, tg_id)
|
||||||
|
balance = int(balance or 0)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>💵 Изменение баланса</b>"
|
||||||
|
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||||
|
f"\n💰 Баланс: <b>{balance}Р</b>"
|
||||||
|
f"\n📊 Последние операции (5):"
|
||||||
|
)
|
||||||
|
|
||||||
|
if records:
|
||||||
|
for amount, payment_system, status, created_at in records:
|
||||||
|
date = created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
text += (
|
||||||
|
f"\n<blockquote>💸 Сумма: {amount} | {payment_system}"
|
||||||
|
f"\n📌 Статус: {status}"
|
||||||
|
f"\n⏳ Дата: {date}</blockquote>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text += "\n <i>🚫 Отсутствуют</i>"
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=await build_users_balance_kb(session, tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_balance_add"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_balance_add(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
amount = callback_data.data
|
||||||
|
|
||||||
|
if amount is not None:
|
||||||
|
amount = int(amount)
|
||||||
|
old_balance = await get_balance(session, tg_id)
|
||||||
|
|
||||||
|
if amount >= 0:
|
||||||
|
await update_balance(session, tg_id, amount)
|
||||||
|
new_balance = old_balance + amount
|
||||||
|
else:
|
||||||
|
new_balance = max(0, old_balance + amount)
|
||||||
|
await set_user_balance(session, tg_id, new_balance)
|
||||||
|
|
||||||
|
if old_balance != new_balance:
|
||||||
|
await handle_balance_change(callback_query, callback_data, session)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(tg_id=tg_id, op_type="add")
|
||||||
|
await state.set_state(UserEditorState.waiting_for_balance)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:",
|
||||||
|
reply_markup=build_users_balance_change_kb(tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_balance_take"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_balance_take(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
await state.update_data(tg_id=tg_id, op_type="take")
|
||||||
|
await state.set_state(UserEditorState.waiting_for_balance)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:",
|
||||||
|
reply_markup=build_users_balance_change_kb(tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_balance_set"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_balance_set(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
await state.update_data(tg_id=tg_id, op_type="set")
|
||||||
|
await state.set_state(UserEditorState.waiting_for_balance)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✍️ Введите баланс, который хотите установить пользователю:",
|
||||||
|
reply_markup=build_users_balance_change_kb(tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(UserEditorState.waiting_for_balance, IsAdminFilter())
|
||||||
|
async def handle_balance_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
tg_id = data.get("tg_id")
|
||||||
|
op_type = data.get("op_type")
|
||||||
|
|
||||||
|
if not message.text.isdigit() or int(message.text) < 0:
|
||||||
|
await message.answer(
|
||||||
|
text="🚫 Пожалуйста, введите корректную сумму!",
|
||||||
|
reply_markup=build_users_balance_change_kb(tg_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
amount = int(message.text)
|
||||||
|
|
||||||
|
if op_type == "add":
|
||||||
|
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
|
||||||
|
await update_balance(session, tg_id, amount)
|
||||||
|
elif op_type == "take":
|
||||||
|
current_balance = await get_balance(session, tg_id)
|
||||||
|
new_balance = max(0, current_balance - amount)
|
||||||
|
deducted = current_balance if amount > current_balance else amount
|
||||||
|
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
|
||||||
|
await set_user_balance(session, tg_id, new_balance)
|
||||||
|
else:
|
||||||
|
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
|
||||||
|
await set_user_balance(session, tg_id, amount)
|
||||||
|
|
||||||
|
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "restore_trials"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def confirm_restore_trials(callback_query: types.CallbackQuery):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="✅ Подтвердить",
|
||||||
|
callback_data=AdminPanelCallback(action="confirm_restore_trials").pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=(
|
||||||
|
"⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
|
||||||
|
"Только для тех, у кого нет подписок (активных или истекших)!"
|
||||||
|
),
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "confirm_restore_trials"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def restore_trials(callback_query: types.CallbackQuery, session: AsyncSession):
|
||||||
|
users_result = await session.execute(select(User.tg_id).where(User.trial == 1))
|
||||||
|
users_with_trial_used = [row[0] for row in users_result.all()]
|
||||||
|
|
||||||
|
users_to_reset = []
|
||||||
|
for tg_id in users_with_trial_used:
|
||||||
|
has_keys = await session.execute(select(Key.tg_id).where(Key.tg_id == tg_id).limit(1))
|
||||||
|
if not has_keys.scalar():
|
||||||
|
users_to_reset.append(tg_id)
|
||||||
|
|
||||||
|
if users_to_reset:
|
||||||
|
stmt = update(User).where(User.tg_id.in_(users_to_reset)).values(trial=0)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Пробники восстановлены для {len(users_to_reset)} пользователей без подписок.",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_export_referrals"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_users_export_referrals(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
referrer_tg_id = callback_data.tg_id
|
||||||
|
|
||||||
|
csv_file = await export_referrals_csv(referrer_tg_id, session)
|
||||||
|
|
||||||
|
if csv_file is None:
|
||||||
|
await callback_query.message.answer("У пользователя нет рефералов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=csv_file,
|
||||||
|
caption=f"Список рефералов для пользователя {referrer_tg_id}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_user_search(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_id: int,
|
||||||
|
edit: bool = False,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at).where(User.tg_id == tg_id)
|
||||||
|
result_user = await session.execute(stmt_user)
|
||||||
|
user_data = result_user.first()
|
||||||
|
|
||||||
|
if not user_data:
|
||||||
|
await message.answer(
|
||||||
|
text="🚫 Пользователь с указанным ID не найден!",
|
||||||
|
reply_markup=build_admin_back_kb(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
username, balance, created_at, updated_at = user_data
|
||||||
|
balance = int(balance or 0)
|
||||||
|
created_at_str = created_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||||
|
updated_at_str = updated_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||||
|
|
||||||
|
stmt_ref_count = select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == tg_id)
|
||||||
|
result_ref = await session.execute(stmt_ref_count)
|
||||||
|
referral_count = result_ref.scalar_one()
|
||||||
|
|
||||||
|
stmt_ref_by = select(Referral.referrer_tg_id).where(Referral.referred_tg_id == tg_id).limit(1)
|
||||||
|
result_ref_by = await session.execute(stmt_ref_by)
|
||||||
|
referrer_tg_id = result_ref_by.scalar_one_or_none()
|
||||||
|
|
||||||
|
referrer_text = None
|
||||||
|
if referrer_tg_id:
|
||||||
|
stmt_referrer = select(User.username).where(User.tg_id == referrer_tg_id)
|
||||||
|
result_referrer = await session.execute(stmt_referrer)
|
||||||
|
ref_username = result_referrer.scalar_one_or_none()
|
||||||
|
if ref_username:
|
||||||
|
referrer_text = f"🤝 Пригласил: @{ref_username} ({referrer_tg_id})"
|
||||||
|
else:
|
||||||
|
referrer_text = f"🤝 Пригласил: {referrer_tg_id}"
|
||||||
|
|
||||||
|
stmt = select(
|
||||||
|
func.count(Payment.id),
|
||||||
|
func.coalesce(func.sum(Payment.amount), 0),
|
||||||
|
).where(Payment.status == "success", Payment.tg_id == tg_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
topups_amount, topups_sum = result.one_or_none() or (0, 0)
|
||||||
|
|
||||||
|
stmt_keys = select(Key).where(Key.tg_id == tg_id)
|
||||||
|
result_keys = await session.execute(stmt_keys)
|
||||||
|
key_records = result_keys.scalars().all()
|
||||||
|
|
||||||
|
body = Text(
|
||||||
|
f"🆔 ID: {tg_id}\n",
|
||||||
|
f"📄 Логин: @{username}" if username else "📄 Логин: —",
|
||||||
|
"\n",
|
||||||
|
f"📅 Дата регистрации: {created_at_str}\n",
|
||||||
|
f"🏃 Дата активности: {updated_at_str}\n",
|
||||||
|
f"💰 Баланс: {balance} Р.\n",
|
||||||
|
f"💳 Пополнения: {topups_sum} Р. ({topups_amount} шт.)\n",
|
||||||
|
f"👥 Количество рефералов: {referral_count}\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
if referrer_text:
|
||||||
|
body += Text(referrer_text, "\n")
|
||||||
|
|
||||||
|
text_builder = Text(Bold("📊 Информация о пользователе"), "\n\n", BlockQuote(body))
|
||||||
|
|
||||||
|
text = text_builder.as_html()
|
||||||
|
|
||||||
|
stmt_ban = select(1).where(ManualBan.tg_id == tg_id).limit(1)
|
||||||
|
result_ban = await session.execute(stmt_ban)
|
||||||
|
is_banned = result_ban.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
|
||||||
|
|
||||||
|
if edit:
|
||||||
|
try:
|
||||||
|
await message.edit_text(text=text, reply_markup=kb, disable_web_page_preview=True)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await message.answer(text=text, reply_markup=kb, disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminUserEditorCallback.filter(F.action == "users_editor"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_users_editor(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: AdminUserEditorCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await process_user_search(
|
||||||
|
callback.message,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
tg_id=callback_data.tg_id,
|
||||||
|
edit=callback_data.edit,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class UserEditorState(StatesGroup):
|
||||||
|
waiting_for_user_data = State()
|
||||||
|
waiting_for_key_name = State()
|
||||||
|
waiting_for_balance = State()
|
||||||
|
waiting_for_expiry_time = State()
|
||||||
|
waiting_for_message_text = State()
|
||||||
|
preview_message = State()
|
||||||
|
selecting_cluster = State()
|
||||||
|
selecting_duration = State()
|
||||||
|
selecting_country = State()
|
||||||
|
|
||||||
|
|
||||||
|
class RenewTariffState(StatesGroup):
|
||||||
|
selecting_group = State()
|
||||||
|
selecting_tariff = State()
|
||||||
|
|
||||||
|
|
||||||
|
class BanUserStates(StatesGroup):
|
||||||
|
waiting_for_reason = State()
|
||||||
|
waiting_for_ban_duration = State()
|
||||||
|
waiting_for_forever_reason = State()
|
||||||
@@ -7,7 +7,6 @@ import pytz
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
CallbackQuery,
|
CallbackQuery,
|
||||||
FSInputFile,
|
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
Message,
|
Message,
|
||||||
WebAppInfo,
|
WebAppInfo,
|
||||||
@@ -15,7 +14,8 @@ from aiogram.types import (
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from bot import bot
|
from bot import bot
|
||||||
from config import CONNECT_PHONE_BUTTON, REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL
|
from config import REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
get_key_details,
|
get_key_details,
|
||||||
get_tariff_by_id,
|
get_tariff_by_id,
|
||||||
@@ -25,10 +25,8 @@ from database import (
|
|||||||
)
|
)
|
||||||
from handlers.buttons import (
|
from handlers.buttons import (
|
||||||
CONNECT_DEVICE,
|
CONNECT_DEVICE,
|
||||||
CONNECT_PHONE,
|
|
||||||
MAIN_MENU,
|
MAIN_MENU,
|
||||||
MY_SUB,
|
MY_SUB,
|
||||||
PC_BUTTON,
|
|
||||||
ROUTER_BUTTON,
|
ROUTER_BUTTON,
|
||||||
SUPPORT,
|
SUPPORT,
|
||||||
TV_BUTTON,
|
TV_BUTTON,
|
||||||
@@ -179,12 +177,12 @@ async def key_cluster_mode(
|
|||||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||||
else:
|
else:
|
||||||
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||||
use_webapp = REMNAWAVE_WEBAPP
|
use_webapp = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||||
if REMNAWAVE_WEBAPP and final_link:
|
if use_webapp and final_link:
|
||||||
try:
|
try:
|
||||||
webapp_override_results = await run_hooks(
|
webapp_override_results = await run_hooks(
|
||||||
"remnawave_webapp_override",
|
"remnawave_webapp_override",
|
||||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
remnawave_webapp=use_webapp,
|
||||||
final_link=final_link,
|
final_link=final_link,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
@@ -196,20 +194,20 @@ async def key_cluster_mode(
|
|||||||
use_webapp = hook_result["override"]
|
use_webapp = hook_result["override"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[REMNAWAVE_WEBAPP_OVERRIDE] Ошибка при применении хуков: {e}")
|
logger.warning(f"[REMNAWAVE_WEBAPP_OVERRIDE] Ошибка при применении хуков: {e}")
|
||||||
|
|
||||||
if use_webapp and final_link:
|
if (
|
||||||
|
use_webapp
|
||||||
|
and final_link
|
||||||
|
and isinstance(final_link, str)
|
||||||
|
and final_link.startswith(("http://", "https://"))
|
||||||
|
):
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||||
elif CONNECT_PHONE_BUTTON:
|
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
|
||||||
builder.row(
|
|
||||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
|
||||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||||
|
|
||||||
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
|
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
|
||||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ from bot import bot
|
|||||||
from config import (
|
from config import (
|
||||||
ADMIN_PASSWORD,
|
ADMIN_PASSWORD,
|
||||||
ADMIN_USERNAME,
|
ADMIN_USERNAME,
|
||||||
CONNECT_PHONE_BUTTON,
|
|
||||||
HAPP_CRYPTOLINK,
|
HAPP_CRYPTOLINK,
|
||||||
REMNAWAVE_LOGIN,
|
REMNAWAVE_LOGIN,
|
||||||
REMNAWAVE_PASSWORD,
|
REMNAWAVE_PASSWORD,
|
||||||
REMNAWAVE_WEBAPP,
|
REMNAWAVE_WEBAPP,
|
||||||
SUPPORT_CHAT_URL,
|
SUPPORT_CHAT_URL,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_user,
|
add_user,
|
||||||
check_server_name_by_cluster,
|
check_server_name_by_cluster,
|
||||||
@@ -41,10 +41,8 @@ from database.models import Key, Server, Tariff
|
|||||||
from handlers.buttons import (
|
from handlers.buttons import (
|
||||||
BACK,
|
BACK,
|
||||||
CONNECT_DEVICE,
|
CONNECT_DEVICE,
|
||||||
CONNECT_PHONE,
|
|
||||||
MAIN_MENU,
|
MAIN_MENU,
|
||||||
MY_SUB,
|
MY_SUB,
|
||||||
PC_BUTTON,
|
|
||||||
ROUTER_BUTTON,
|
ROUTER_BUTTON,
|
||||||
SUPPORT,
|
SUPPORT,
|
||||||
TV_BUTTON,
|
TV_BUTTON,
|
||||||
@@ -488,7 +486,7 @@ async def finalize_key_creation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not remnawave_link:
|
if not remnawave_link:
|
||||||
if HAPP_CRYPTOLINK:
|
if bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)):
|
||||||
happ = sub.get("happ") or {}
|
happ = sub.get("happ") or {}
|
||||||
remnawave_link = happ.get("cryptoLink") or happ.get("link")
|
remnawave_link = happ.get("cryptoLink") or happ.get("link")
|
||||||
if not remnawave_link:
|
if not remnawave_link:
|
||||||
@@ -593,12 +591,12 @@ async def finalize_key_creation(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
use_webapp = REMNAWAVE_WEBAPP
|
use_webapp = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||||
if REMNAWAVE_WEBAPP and webapp_url:
|
if use_webapp and webapp_url:
|
||||||
try:
|
try:
|
||||||
webapp_override_results = await run_hooks(
|
webapp_override_results = await run_hooks(
|
||||||
"remnawave_webapp_override",
|
"remnawave_webapp_override",
|
||||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
remnawave_webapp=use_webapp,
|
||||||
final_link=final_link,
|
final_link=final_link,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
@@ -617,15 +615,9 @@ async def finalize_key_creation(
|
|||||||
else:
|
else:
|
||||||
if use_webapp and webapp_url:
|
if use_webapp and webapp_url:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url)))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url)))
|
||||||
|
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
|
||||||
elif CONNECT_PHONE_BUTTON:
|
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
|
||||||
builder.row(
|
|
||||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
|
||||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from config import (
|
|||||||
USE_COUNTRY_SELECTION,
|
USE_COUNTRY_SELECTION,
|
||||||
USE_NEW_PAYMENT_FLOW,
|
USE_NEW_PAYMENT_FLOW,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_user,
|
add_user,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
@@ -89,7 +90,8 @@ async def handle_key_creation(
|
|||||||
try:
|
try:
|
||||||
current_time = datetime.now(moscow_tz)
|
current_time = datetime.now(moscow_tz)
|
||||||
|
|
||||||
if not TRIAL_TIME_DISABLE:
|
trial_time_disabled = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||||
|
if not trial_time_disabled:
|
||||||
trial_status = await get_trial(session, tg_id)
|
trial_status = await get_trial(session, tg_id)
|
||||||
if trial_status in [0, -1]:
|
if trial_status in [0, -1]:
|
||||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||||
@@ -109,7 +111,8 @@ async def handle_key_creation(
|
|||||||
|
|
||||||
trial_tariff = trial_tariffs[0]
|
trial_tariff = trial_tariffs[0]
|
||||||
base_days = trial_tariff["duration_days"]
|
base_days = trial_tariff["duration_days"]
|
||||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
extra_days_value = int(NOTIFICATIONS_CONFIG.get("EXTRA_DAYS_AFTER_EXPIRY", NOTIFY_EXTRA_DAYS))
|
||||||
|
extra_days = extra_days_value if trial_status == -1 else 0
|
||||||
total_days = base_days + extra_days
|
total_days = base_days + extra_days
|
||||||
expiry_time = current_time + timedelta(days=total_days)
|
expiry_time = current_time + timedelta(days=total_days)
|
||||||
|
|
||||||
@@ -156,8 +159,6 @@ async def handle_key_creation(
|
|||||||
if tariffs:
|
if tariffs:
|
||||||
group_code = tariffs[0].get("group_code")
|
group_code = tariffs[0].get("group_code")
|
||||||
if group_code:
|
if group_code:
|
||||||
from database.notifications import check_hot_lead_discount
|
|
||||||
|
|
||||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||||
|
|
||||||
if discount_info and discount_info.get("available"):
|
if discount_info and discount_info.get("available"):
|
||||||
@@ -278,8 +279,10 @@ async def handle_key_creation(
|
|||||||
if discount_info and discount_info.get("available"):
|
if discount_info and discount_info.get("available"):
|
||||||
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
||||||
expires_at = discount_info["expires_at"]
|
expires_at = discount_info["expires_at"]
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
time_left = format_discount_time_left(
|
time_left = format_discount_time_left(
|
||||||
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
|
expires_at - timedelta(hours=discount_active_hours),
|
||||||
|
discount_active_hours,
|
||||||
)
|
)
|
||||||
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
||||||
|
|
||||||
@@ -486,7 +489,9 @@ async def create_key(
|
|||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
if USE_COUNTRY_SELECTION:
|
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||||
|
|
||||||
|
if use_country_selection:
|
||||||
await key_country_mode(
|
await key_country_mode(
|
||||||
tg_id=tg_id,
|
tg_id=tg_id,
|
||||||
expiry_time=expiry_time,
|
expiry_time=expiry_time,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import DISCOUNT_ACTIVE_HOURS
|
from config import DISCOUNT_ACTIVE_HOURS
|
||||||
|
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||||
from database import get_keys, get_tariffs
|
from database import get_keys, get_tariffs
|
||||||
from database.models import Notification
|
from database.models import Notification
|
||||||
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
|
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
|
||||||
@@ -37,8 +38,10 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
|||||||
await callback.message.edit_text("❌ Скидка недоступна.")
|
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
|
if now - last_time > timedelta(hours=discount_active_hours):
|
||||||
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
|
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -49,13 +52,13 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
|||||||
builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
|
builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
|
||||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
|
expires_at = last_time + timedelta(hours=discount_active_hours)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||||
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
|
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
|
||||||
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
|
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
|
||||||
f"</blockquote>\n"
|
f"</blockquote>\n"
|
||||||
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, DISCOUNT_ACTIVE_HOURS)} — не упустите свой шанс!</b>",
|
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите свой шанс!</b>",
|
||||||
reply_markup=builder.as_markup(),
|
reply_markup=builder.as_markup(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -82,7 +85,6 @@ async def handle_discount_tariff_selection(callback: CallbackQuery, session, sta
|
|||||||
data=f"select_tariff_plan|{tariff_id}",
|
data=f"select_tariff_plan|{tariff_id}",
|
||||||
)
|
)
|
||||||
await select_tariff_plan(fake_callback, session=session, state=state)
|
await select_tariff_plan(fake_callback, session=session, state=state)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
|
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
|
||||||
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
|
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
|
||||||
@@ -104,8 +106,10 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
|||||||
await callback.message.edit_text("❌ Скидка недоступна.")
|
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
|
if now - last_time > timedelta(hours=discount_active_hours):
|
||||||
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
|
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -121,7 +125,7 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
|||||||
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
||||||
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
|
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
|
||||||
f"</blockquote>\n"
|
f"</blockquote>\n"
|
||||||
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, DISCOUNT_ACTIVE_HOURS)} — не упустите шанс!</b>",
|
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, discount_active_hours)} — не упустите шанс!</b>",
|
||||||
reply_markup=builder.as_markup(),
|
reply_markup=builder.as_markup(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot import bot
|
from bot import bot
|
||||||
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
|
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
|
||||||
|
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
check_tariff_exists,
|
check_tariff_exists,
|
||||||
get_balance,
|
get_balance,
|
||||||
get_key_by_server,
|
get_key_by_server,
|
||||||
get_key_details,
|
get_key_details,
|
||||||
get_tariff_by_id,
|
get_tariff_by_id,
|
||||||
get_tariffs,
|
|
||||||
update_balance,
|
update_balance,
|
||||||
update_key_expiry,
|
update_key_expiry,
|
||||||
)
|
)
|
||||||
@@ -179,7 +179,10 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
|
sorted_subgroups = sorted(
|
||||||
|
[k for k in grouped_tariffs if k],
|
||||||
|
key=lambda x: (subgroup_weights.get(x, 999999), x),
|
||||||
|
)
|
||||||
|
|
||||||
for subgroup in sorted_subgroups:
|
for subgroup in sorted_subgroups:
|
||||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||||
@@ -210,10 +213,12 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
|||||||
|
|
||||||
discount_message = ""
|
discount_message = ""
|
||||||
if discount_info.get("available"):
|
if discount_info.get("available"):
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
||||||
expires_at = discount_info["expires_at"]
|
expires_at = discount_info["expires_at"]
|
||||||
time_left = format_discount_time_left(
|
time_left = format_discount_time_left(
|
||||||
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
|
expires_at - timedelta(hours=discount_active_hours),
|
||||||
|
discount_active_hours,
|
||||||
)
|
)
|
||||||
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
||||||
|
|
||||||
@@ -362,10 +367,12 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
|||||||
|
|
||||||
discount_message = ""
|
discount_message = ""
|
||||||
if discount_info.get("available"):
|
if discount_info.get("available"):
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
||||||
expires_at = discount_info["expires_at"]
|
expires_at = discount_info["expires_at"]
|
||||||
time_left = format_discount_time_left(
|
time_left = format_discount_time_left(
|
||||||
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
|
expires_at - timedelta(hours=discount_active_hours),
|
||||||
|
discount_active_hours,
|
||||||
)
|
)
|
||||||
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
|
||||||
|
|
||||||
@@ -383,8 +390,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
|||||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||||
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||||
tg_id = callback_query.from_user.id
|
tg_id = callback_query.from_user.id
|
||||||
tariff_id = callback_query.data.split("|")[1]
|
tariff_id = int(callback_query.data.split("|")[1])
|
||||||
tariff_id = int(tariff_id)
|
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
client_id = data.get("renew_client_id")
|
client_id = data.get("renew_client_id")
|
||||||
|
|||||||
+40
-46
@@ -17,7 +17,6 @@ from sqlalchemy import update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
CONNECT_PHONE_BUTTON,
|
|
||||||
ENABLE_DELETE_KEY_BUTTON,
|
ENABLE_DELETE_KEY_BUTTON,
|
||||||
HAPP_CRYPTOLINK,
|
HAPP_CRYPTOLINK,
|
||||||
HWID_RESET_BUTTON,
|
HWID_RESET_BUTTON,
|
||||||
@@ -28,6 +27,7 @@ from config import (
|
|||||||
TOGGLE_CLIENT,
|
TOGGLE_CLIENT,
|
||||||
USE_COUNTRY_SELECTION,
|
USE_COUNTRY_SELECTION,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||||
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
|
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
|
||||||
from database.models import Key
|
from database.models import Key
|
||||||
from handlers.buttons import (
|
from handlers.buttons import (
|
||||||
@@ -35,12 +35,10 @@ from handlers.buttons import (
|
|||||||
BACK,
|
BACK,
|
||||||
CHANGE_LOCATION,
|
CHANGE_LOCATION,
|
||||||
CONNECT_DEVICE,
|
CONNECT_DEVICE,
|
||||||
CONNECT_PHONE,
|
|
||||||
DELETE,
|
DELETE,
|
||||||
FREEZE,
|
FREEZE,
|
||||||
HWID_BUTTON,
|
HWID_BUTTON,
|
||||||
MAIN_MENU,
|
MAIN_MENU,
|
||||||
PC_BUTTON,
|
|
||||||
QR,
|
QR,
|
||||||
RENEW_KEY,
|
RENEW_KEY,
|
||||||
ROUTER_BUTTON,
|
ROUTER_BUTTON,
|
||||||
@@ -71,6 +69,7 @@ from panels.remnawave import RemnawaveAPI
|
|||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
class RenameKeyState(StatesGroup):
|
class RenameKeyState(StatesGroup):
|
||||||
@@ -105,17 +104,13 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
|
|||||||
reply_markup=inline_keyboard,
|
reply_markup=inline_keyboard,
|
||||||
media_path=image_path,
|
media_path=image_path,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
error_message = f"Ошибка при получении ключей: {e}"
|
error_message = f"Ошибка при получении ключей: {error}"
|
||||||
await target_message.answer(text=error_message)
|
await target_message.answer(text=error_message)
|
||||||
|
|
||||||
|
|
||||||
async def build_keys_response(records, session):
|
async def build_keys_response(records, session):
|
||||||
"""
|
|
||||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
|
||||||
"""
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
|
||||||
|
|
||||||
if records:
|
if records:
|
||||||
response_message = KEYS_HEADER
|
response_message = KEYS_HEADER
|
||||||
@@ -139,7 +134,7 @@ async def build_keys_response(records, session):
|
|||||||
tariff = await get_tariff_by_id(session, record.tariff_id)
|
tariff = await get_tariff_by_id(session, record.tariff_id)
|
||||||
if tariff and tariff.get("vless"):
|
if tariff and tariff.get("vless"):
|
||||||
is_vless = True
|
is_vless = True
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
icon = "📶" if is_vless else "🔑"
|
icon = "📶" if is_vless else "🔑"
|
||||||
@@ -204,10 +199,9 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A
|
|||||||
update(Key).where(Key.tg_id == message.chat.id, Key.client_id == client_id).values(alias=alias)
|
update(Key).where(Key.tg_id == message.chat.id, Key.client_id == client_id).values(alias=alias)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
except Exception as error:
|
||||||
except Exception as e:
|
|
||||||
await message.answer("❌ Не удалось переименовать подписку.")
|
await message.answer("❌ Не удалось переименовать подписку.")
|
||||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
logger.error(f"Ошибка при обновлении alias: {error}")
|
||||||
finally:
|
finally:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
@@ -293,8 +287,8 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
|||||||
if user_data:
|
if user_data:
|
||||||
used_bytes = user_data.get("usedTrafficBytes", 0)
|
used_bytes = user_data.get("usedTrafficBytes", 0)
|
||||||
remna_used_gb = round(used_bytes / 1073741824, 1)
|
remna_used_gb = round(used_bytes / 1073741824, 1)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {e}")
|
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {error}")
|
||||||
|
|
||||||
tariff_name = ""
|
tariff_name = ""
|
||||||
traffic_limit = 0
|
traffic_limit = 0
|
||||||
@@ -310,12 +304,16 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
|||||||
|
|
||||||
tariff_duration = tariff_name
|
tariff_duration = tariff_name
|
||||||
|
|
||||||
|
country_selection_enabled = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||||
|
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||||
|
happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||||
|
|
||||||
response_message = key_message(
|
response_message = key_message(
|
||||||
final_link,
|
final_link,
|
||||||
formatted_expiry_date,
|
formatted_expiry_date,
|
||||||
days_left_message,
|
days_left_message,
|
||||||
server_name,
|
server_name,
|
||||||
server_name if USE_COUNTRY_SELECTION else None,
|
server_name if country_selection_enabled else None,
|
||||||
hwid_count=hwid_count if device_limit is not None else 0,
|
hwid_count=hwid_count if device_limit is not None else 0,
|
||||||
tariff_name=tariff_duration,
|
tariff_name=tariff_duration,
|
||||||
traffic_limit=traffic_limit,
|
traffic_limit=traffic_limit,
|
||||||
@@ -325,12 +323,12 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
|||||||
remna_used_gb=remna_used_gb,
|
remna_used_gb=remna_used_gb,
|
||||||
)
|
)
|
||||||
|
|
||||||
use_webapp = REMNAWAVE_WEBAPP
|
use_webapp = remnawave_webapp_enabled
|
||||||
if is_full_remnawave and final_link and REMNAWAVE_WEBAPP and not HAPP_CRYPTOLINK:
|
if is_full_remnawave and final_link and remnawave_webapp_enabled and not happ_cryptolink_enabled:
|
||||||
try:
|
try:
|
||||||
webapp_override_results = await run_hooks(
|
webapp_override_results = await run_hooks(
|
||||||
"remnawave_webapp_override",
|
"remnawave_webapp_override",
|
||||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
remnawave_webapp=remnawave_webapp_enabled,
|
||||||
final_link=final_link,
|
final_link=final_link,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
@@ -340,47 +338,41 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
|||||||
use_webapp = hook_result
|
use_webapp = hook_result
|
||||||
elif isinstance(hook_result, dict) and "override" in hook_result:
|
elif isinstance(hook_result, dict) and "override" in hook_result:
|
||||||
use_webapp = hook_result["override"]
|
use_webapp = hook_result["override"]
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.warning(f"[REMNAWAVE_WEBAPP_OVERRIDE] Ошибка при применении хуков: {e}")
|
logger.warning(f"[REMNAWAVE_WEBAPP_OVERRIDE] Ошибка при применении хуков: {error}")
|
||||||
|
|
||||||
if is_full_remnawave and final_link and use_webapp and not HAPP_CRYPTOLINK:
|
if is_full_remnawave and final_link and use_webapp and not happ_cryptolink_enabled:
|
||||||
if vless_enabled:
|
if vless_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
|
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
|
||||||
else:
|
else:
|
||||||
if CONNECT_PHONE_BUTTON:
|
if vless_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||||
if vless_enabled:
|
|
||||||
builder.row(InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"))
|
|
||||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
|
||||||
else:
|
|
||||||
builder.row(
|
|
||||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
|
||||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if vless_enabled:
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
|
||||||
else:
|
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
|
||||||
|
|
||||||
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}"))
|
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}"))
|
||||||
|
|
||||||
if HWID_RESET_BUTTON and hwid_count > 0:
|
hwid_reset_enabled = bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON))
|
||||||
|
qrcode_enabled = bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE))
|
||||||
|
delete_key_enabled = bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON))
|
||||||
|
toggle_client_enabled = bool(BUTTONS_CONFIG.get("TOGGLE_CLIENT_BUTTON_ENABLE", TOGGLE_CLIENT))
|
||||||
|
|
||||||
|
if hwid_reset_enabled and hwid_count > 0:
|
||||||
builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=f"reset_hwid|{key_name}"))
|
builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=f"reset_hwid|{key_name}"))
|
||||||
|
|
||||||
if QRCODE:
|
if qrcode_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
|
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
|
||||||
|
|
||||||
if ENABLE_DELETE_KEY_BUTTON:
|
if delete_key_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"))
|
builder.row(InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"))
|
||||||
|
|
||||||
if USE_COUNTRY_SELECTION:
|
if country_selection_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
||||||
|
|
||||||
if TOGGLE_CLIENT:
|
if toggle_client_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
|
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
|
||||||
|
|
||||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
@@ -437,12 +429,14 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
|||||||
"after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name
|
"after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name
|
||||||
)
|
)
|
||||||
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
|
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
|
||||||
kb = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
if callback_query.message.text:
|
if callback_query.message.text:
|
||||||
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=kb.as_markup())
|
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=builder.as_markup())
|
||||||
else:
|
else:
|
||||||
await callback_query.message.edit_caption(caption="✅ Устройства сброшены", reply_markup=kb.as_markup())
|
await callback_query.message.edit_caption(
|
||||||
|
caption="✅ Устройства сброшены", reply_markup=builder.as_markup()
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
image_path = os.path.join("img", "pic_view.jpg")
|
image_path = os.path.join("img", "pic_view.jpg")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||||
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
|
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from logger import logger
|
from logger import logger
|
||||||
from panels._3xui import get_vless_link_for_client, get_xui_instance
|
from panels._3xui import get_vless_link_for_client, get_xui_instance
|
||||||
from panels.remnawave import RemnawaveAPI
|
from panels.remnawave import RemnawaveAPI
|
||||||
@@ -23,6 +24,8 @@ async def _is_vless_tariff(session: AsyncSession, email: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _try_build_remna_vless(servers: list, email: str) -> tuple[str | None, str | None]:
|
async def _try_build_remna_vless(servers: list, email: str) -> tuple[str | None, str | None]:
|
||||||
|
happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||||
|
|
||||||
si = servers[0]
|
si = servers[0]
|
||||||
remna = RemnawaveAPI(si["api_url"])
|
remna = RemnawaveAPI(si["api_url"])
|
||||||
ok = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
ok = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||||
@@ -50,7 +53,7 @@ async def _try_build_remna_vless(servers: list, email: str) -> tuple[str | None,
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if HAPP_CRYPTOLINK and happ_link:
|
if happ_cryptolink_enabled and happ_link:
|
||||||
return best, happ_link
|
return best, happ_link
|
||||||
|
|
||||||
sub_url = data.get("subscriptionUrl")
|
sub_url = data.get("subscriptionUrl")
|
||||||
@@ -102,6 +105,8 @@ async def make_aggregated_link(
|
|||||||
remna_link_override: str | None = None,
|
remna_link_override: str | None = None,
|
||||||
plan=None,
|
plan=None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
legacy_links_enabled = bool(MODES_CONFIG.get("LEGACY_LINKS_ENABLED", LEGACY_LINKS))
|
||||||
|
|
||||||
servers = (
|
servers = (
|
||||||
await filter_cluster_by_subgroup(session, cluster_all, subgroup_code, cluster_id)
|
await filter_cluster_by_subgroup(session, cluster_all, subgroup_code, cluster_id)
|
||||||
if subgroup_code
|
if subgroup_code
|
||||||
@@ -125,7 +130,7 @@ async def make_aggregated_link(
|
|||||||
base = PUBLIC_LINK.rstrip("/")
|
base = PUBLIC_LINK.rstrip("/")
|
||||||
|
|
||||||
if vless_needed:
|
if vless_needed:
|
||||||
if LEGACY_LINKS:
|
if legacy_links_enabled:
|
||||||
if xui:
|
if xui:
|
||||||
xui_link = await _try_build_3xui_vless(xui, email)
|
xui_link = await _try_build_3xui_vless(xui, email)
|
||||||
if xui_link:
|
if xui_link:
|
||||||
@@ -158,14 +163,12 @@ async def make_aggregated_link(
|
|||||||
return f"{base}/{email}/{tg_id}"
|
return f"{base}/{email}/{tg_id}"
|
||||||
|
|
||||||
if remna and not xui:
|
if remna and not xui:
|
||||||
if LEGACY_LINKS:
|
if legacy_links_enabled:
|
||||||
logger.info("[agg_link] LEGACY non-vless -> base link")
|
logger.info("[agg_link] LEGACY non-vless -> base link")
|
||||||
return f"{base}/{email}/{tg_id}"
|
return f"{base}/{email}/{tg_id}"
|
||||||
best_vless, sub_url = await _try_build_remna_vless(remna, email)
|
best_vless, sub_url = await _try_build_remna_vless(remna, email)
|
||||||
if remna_link_override and (
|
if remna_link_override and (
|
||||||
remna_link_override.lower().startswith("vless://") or
|
remna_link_override.lower().startswith("vless://") or remna_link_override.startswith(("http", "happ://"))
|
||||||
remna_link_override.startswith("http") or
|
|
||||||
remna_link_override.startswith("happ://")
|
|
||||||
):
|
):
|
||||||
logger.info("[agg_link] choose override Remnawave (non-vless)")
|
logger.info("[agg_link] choose override Remnawave (non-vless)")
|
||||||
return remna_link_override
|
return remna_link_override
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||||
from database import get_servers, get_tariff_by_id, store_key
|
from database import get_servers, get_tariff_by_id, store_key
|
||||||
from database.models import User
|
from database.models import User
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
|
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
|
||||||
from hooks.hooks import run_hooks
|
from hooks.hooks import run_hooks
|
||||||
from logger import (
|
from logger import (
|
||||||
@@ -152,7 +153,10 @@ async def create_key_on_cluster(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{PANEL_REMNA} Ошибка сборки VLESS: {e}")
|
logger.error(f"{PANEL_REMNA} Ошибка сборки VLESS: {e}")
|
||||||
|
|
||||||
use_crypto_link = HAPP_CRYPTOLINK
|
base_use_crypto_link = bool(
|
||||||
|
MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)
|
||||||
|
)
|
||||||
|
use_crypto_link = base_use_crypto_link
|
||||||
try:
|
try:
|
||||||
hook_results = await run_hooks(
|
hook_results = await run_hooks(
|
||||||
"happ_cryptolink_override",
|
"happ_cryptolink_override",
|
||||||
@@ -161,7 +165,7 @@ async def create_key_on_cluster(
|
|||||||
session=session,
|
session=session,
|
||||||
email=email,
|
email=email,
|
||||||
tg_id=tg_id,
|
tg_id=tg_id,
|
||||||
happ_cryptolink=HAPP_CRYPTOLINK,
|
happ_cryptolink=base_use_crypto_link,
|
||||||
)
|
)
|
||||||
if hook_results:
|
if hook_results:
|
||||||
for hook_result in hook_results:
|
for hook_result in hook_results:
|
||||||
@@ -170,7 +174,7 @@ async def create_key_on_cluster(
|
|||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[HAPP_CRYPTOLINK_OVERRIDE] Ошибка при применении хуков: {e}")
|
logger.warning(f"[HAPP_CRYPTOLINK_OVERRIDE] Ошибка при применении хуков: {e}")
|
||||||
|
|
||||||
remnawave_key = link_vless or (
|
remnawave_key = link_vless or (
|
||||||
result["happ"]["cryptoLink"] if use_crypto_link else result.get("subscriptionUrl")
|
result["happ"]["cryptoLink"] if use_crypto_link else result.get("subscriptionUrl")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||||
from database import filter_cluster_by_subgroup, update_key_client_id
|
from database import filter_cluster_by_subgroup, update_key_client_id
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from logger import (
|
from logger import (
|
||||||
CLOGGER as logger,
|
CLOGGER as logger,
|
||||||
PANEL_REMNA,
|
PANEL_REMNA,
|
||||||
@@ -42,6 +43,7 @@ async def ensure_on_remnawave(
|
|||||||
|
|
||||||
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
|
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
|
||||||
traffic_bytes = bytes_from_gb(total_gb)
|
traffic_bytes = bytes_from_gb(total_gb)
|
||||||
|
use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||||
|
|
||||||
async def do_update():
|
async def do_update():
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +84,7 @@ async def ensure_on_remnawave(
|
|||||||
new_uuid = created.get("uuid") if isinstance(created, dict) else None
|
new_uuid = created.get("uuid") if isinstance(created, dict) else None
|
||||||
remna_link = None
|
remna_link = None
|
||||||
if isinstance(created, dict):
|
if isinstance(created, dict):
|
||||||
if HAPP_CRYPTOLINK:
|
if use_crypto_link:
|
||||||
remna_link = (
|
remna_link = (
|
||||||
created.get("happ", {}).get("cryptoLink") if isinstance(created.get("happ"), dict) else None
|
created.get("happ", {}).get("cryptoLink") if isinstance(created.get("happ"), dict) else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[
|
|||||||
return lines, headers
|
return lines, headers
|
||||||
return [], {}
|
return [], {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching URL {url}: {e}")
|
logger.debug(f"Error fetching URL {url}: {e}")
|
||||||
return [], {}
|
return [], {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from config import (
|
|||||||
NOTIFY_RENEW_EXPIRED,
|
NOTIFY_RENEW_EXPIRED,
|
||||||
TRIAL_TIME_DISABLE,
|
TRIAL_TIME_DISABLE,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_notification,
|
add_notification,
|
||||||
check_notification_time,
|
check_notification_time,
|
||||||
@@ -67,9 +68,11 @@ notification_lock = asyncio.Lock()
|
|||||||
|
|
||||||
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||||
while True:
|
while True:
|
||||||
|
notification_interval = int(NOTIFICATIONS_CONFIG.get("BASE_NOTIFICATION_MINUTE", NOTIFICATION_TIME))
|
||||||
|
|
||||||
if notification_lock.locked():
|
if notification_lock.locked():
|
||||||
logger.warning("Уведомления уже выполняются. Пропуск...")
|
logger.warning("Уведомления уже выполняются. Пропуск...")
|
||||||
await asyncio.sleep(NOTIFICATION_TIME)
|
await asyncio.sleep(notification_interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with notification_lock:
|
async with notification_lock:
|
||||||
@@ -82,60 +85,89 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
|||||||
try:
|
try:
|
||||||
keys = await get_all_keys(session=session)
|
keys = await get_all_keys(session=session)
|
||||||
keys = [k for k in keys if not k.is_frozen]
|
keys = [k for k in keys if not k.is_frozen]
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка при получении ключей: {e}")
|
logger.error(f"Ошибка при получении ключей: {error}")
|
||||||
keys = []
|
keys = []
|
||||||
|
|
||||||
if not TRIAL_TIME_DISABLE:
|
trial_time_disable = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||||
|
|
||||||
|
if not trial_time_disable:
|
||||||
try:
|
try:
|
||||||
await notify_inactive_trial_users(bot, session)
|
await notify_inactive_trial_users(bot, session)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
|
logger.error(f"Ошибка в notify_inactive_trial_users: {error}")
|
||||||
|
|
||||||
if NOTIFY_24H_ENABLED:
|
notify_24_enabled = bool(NOTIFICATIONS_CONFIG.get("EXPIRY_24H_ENABLED", NOTIFY_24H_ENABLED))
|
||||||
|
notify_24_hours = int(NOTIFICATIONS_CONFIG.get("EXPIRY_24H_BEFORE_HOURS", NOTIFY_24H_HOURS))
|
||||||
|
notify_10_enabled = bool(NOTIFICATIONS_CONFIG.get("EXPIRY_10H_ENABLED", NOTIFY_10H_ENABLED))
|
||||||
|
notify_10_hours = int(NOTIFICATIONS_CONFIG.get("EXPIRY_10H_BEFORE_HOURS", NOTIFY_10H_HOURS))
|
||||||
|
notify_renew_enabled = bool(NOTIFICATIONS_CONFIG.get("RENEW_ENABLED", NOTIFY_RENEW))
|
||||||
|
inactive_traffic_enabled = bool(
|
||||||
|
NOTIFICATIONS_CONFIG.get("INACTIVE_TRAFFIC_ENABLED", NOTIFY_INACTIVE_TRAFFIC)
|
||||||
|
)
|
||||||
|
notify_hot_leads_enabled = bool(NOTIFICATIONS_CONFIG.get("HOT_LEADS_ENABLED", NOTIFY_HOT_LEADS))
|
||||||
|
|
||||||
|
if notify_24_enabled:
|
||||||
try:
|
try:
|
||||||
threshold_24h = int(
|
threshold_24h = int(
|
||||||
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_24H_HOURS)).timestamp() * 1000
|
(datetime.now(moscow_tz) + timedelta(hours=notify_24_hours)).timestamp() * 1000
|
||||||
)
|
)
|
||||||
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
|
await notify_24h_keys(
|
||||||
except Exception as e:
|
bot,
|
||||||
logger.error(f"Ошибка в notify_24h_keys: {e}")
|
session,
|
||||||
|
current_time,
|
||||||
|
threshold_24h,
|
||||||
|
keys,
|
||||||
|
notify_24_hours,
|
||||||
|
notify_renew_enabled,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Ошибка в notify_24h_keys: {error}")
|
||||||
|
|
||||||
if NOTIFY_10H_ENABLED:
|
if notify_10_enabled:
|
||||||
try:
|
try:
|
||||||
threshold_10h = int(
|
threshold_10h = int(
|
||||||
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_10H_HOURS)).timestamp() * 1000
|
(datetime.now(moscow_tz) + timedelta(hours=notify_10_hours)).timestamp() * 1000
|
||||||
)
|
)
|
||||||
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
|
await notify_10h_keys(
|
||||||
except Exception as e:
|
bot,
|
||||||
logger.error(f"Ошибка в notify_10h_keys: {e}")
|
session,
|
||||||
|
current_time,
|
||||||
|
threshold_10h,
|
||||||
|
keys,
|
||||||
|
notify_10_hours,
|
||||||
|
notify_renew_enabled,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Ошибка в notify_10h_keys: {error}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await handle_expired_keys(bot, session, current_time, keys)
|
await handle_expired_keys(bot, session, current_time, keys)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в handle_expired_keys: {e}")
|
logger.error(f"Ошибка в handle_expired_keys: {error}")
|
||||||
|
|
||||||
if NOTIFY_INACTIVE_TRAFFIC:
|
if inactive_traffic_enabled:
|
||||||
try:
|
try:
|
||||||
await notify_users_no_traffic(bot, session, current_time, keys)
|
await notify_users_no_traffic(bot, session, current_time, keys)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
|
logger.error(f"Ошибка в notify_users_no_traffic: {error}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_hooks("periodic_notifications", bot=bot, session=session, keys=keys)
|
await run_hooks("periodic_notifications", bot=bot, session=session, keys=keys)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в хуках periodic_notifications: {e}")
|
logger.error(f"Ошибка в хуках periodic_notifications: {error}")
|
||||||
|
|
||||||
if NOTIFY_HOT_LEADS:
|
if notify_hot_leads_enabled:
|
||||||
try:
|
try:
|
||||||
await notify_hot_leads(bot, session)
|
await notify_hot_leads(bot, session)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в notify_hot_leads: {e}")
|
logger.error(f"Ошибка в notify_hot_leads: {error}")
|
||||||
|
|
||||||
logger.info("Уведомления завершены")
|
logger.info("Уведомления завершены")
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка в periodic_notifications: {e}")
|
logger.error(f"Ошибка в periodic_notifications: {error}")
|
||||||
|
|
||||||
await asyncio.sleep(NOTIFICATION_TIME)
|
await asyncio.sleep(notification_interval)
|
||||||
|
|
||||||
|
|
||||||
async def notify_24h_keys(
|
async def notify_24h_keys(
|
||||||
@@ -144,16 +176,18 @@ async def notify_24h_keys(
|
|||||||
current_time: int,
|
current_time: int,
|
||||||
threshold_time_24h: int,
|
threshold_time_24h: int,
|
||||||
keys: list,
|
keys: list,
|
||||||
|
notification_hours: int,
|
||||||
|
notify_renew_enabled: bool,
|
||||||
):
|
):
|
||||||
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
|
logger.info(f"Начало проверки подписок, истекающих через {notification_hours} часов.")
|
||||||
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
|
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
|
||||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
|
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {notification_hours} часов.")
|
||||||
|
|
||||||
tg_ids = [getattr(key, "tg_id", key["tg_id"]) for key in expiring_keys]
|
tg_ids = [getattr(key, "tg_id", key["tg_id"]) for key in expiring_keys]
|
||||||
emails = [key.email or "" for key in expiring_keys]
|
emails = [key.email or "" for key in expiring_keys]
|
||||||
allowed = await check_notifications_bulk(session, "key_24h", NOTIFY_24H_HOURS, tg_ids=tg_ids, emails=emails)
|
allowed = await check_notifications_bulk(session, "key_24h", notification_hours, tg_ids=tg_ids, emails=emails)
|
||||||
|
|
||||||
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
|
allowed_set = {(user["tg_id"], user["email"]) for user in allowed}
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
for key in expiring_keys:
|
for key in expiring_keys:
|
||||||
@@ -164,7 +198,7 @@ async def notify_24h_keys(
|
|||||||
|
|
||||||
notification_id = f"{email}_key_24h"
|
notification_id = f"{email}_key_24h"
|
||||||
|
|
||||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_24H_HOURS)
|
can_notify = await check_notification_time(session, tg_id, notification_id, hours=notification_hours)
|
||||||
if not can_notify:
|
if not can_notify:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -178,7 +212,7 @@ async def notify_24h_keys(
|
|||||||
tariff_details=expiry_data["tariff_details"],
|
tariff_details=expiry_data["tariff_details"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if NOTIFY_RENEW:
|
if notify_renew_enabled:
|
||||||
try:
|
try:
|
||||||
await process_auto_renew_or_notify(
|
await process_auto_renew_or_notify(
|
||||||
bot,
|
bot,
|
||||||
@@ -189,8 +223,8 @@ async def notify_24h_keys(
|
|||||||
"notify_24h.jpg",
|
"notify_24h.jpg",
|
||||||
notification_text,
|
notification_text,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {error}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
keyboard = build_notification_kb(email)
|
keyboard = build_notification_kb(email)
|
||||||
@@ -217,9 +251,9 @@ async def notify_24h_keys(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||||
)
|
)
|
||||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_24H_HOURS} часов.")
|
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {notification_hours} часов.")
|
||||||
|
|
||||||
logger.info(f"Обработка всех уведомлений за {NOTIFY_24H_HOURS} часов завершена.")
|
logger.info(f"Обработка всех уведомлений за {notification_hours} часов завершена.")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,16 +263,18 @@ async def notify_10h_keys(
|
|||||||
current_time: int,
|
current_time: int,
|
||||||
threshold_time_10h: int,
|
threshold_time_10h: int,
|
||||||
keys: list,
|
keys: list,
|
||||||
|
notification_hours: int,
|
||||||
|
notify_renew_enabled: bool,
|
||||||
):
|
):
|
||||||
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
|
logger.info(f"Начало проверки подписок, истекающих через {notification_hours} часов.")
|
||||||
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
|
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
|
||||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
|
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {notification_hours} часов.")
|
||||||
|
|
||||||
tg_ids = [key.tg_id for key in expiring_keys]
|
tg_ids = [key.tg_id for key in expiring_keys]
|
||||||
emails = [key.email or "" for key in expiring_keys]
|
emails = [key.email or "" for key in expiring_keys]
|
||||||
allowed = await check_notifications_bulk(session, "key_10h", NOTIFY_10H_HOURS, tg_ids=tg_ids, emails=emails)
|
allowed = await check_notifications_bulk(session, "key_10h", notification_hours, tg_ids=tg_ids, emails=emails)
|
||||||
|
|
||||||
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
|
allowed_set = {(user["tg_id"], user["email"]) for user in allowed}
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
for key in expiring_keys:
|
for key in expiring_keys:
|
||||||
@@ -249,7 +285,7 @@ async def notify_10h_keys(
|
|||||||
|
|
||||||
notification_id = f"{email}_key_10h"
|
notification_id = f"{email}_key_10h"
|
||||||
|
|
||||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_10H_HOURS)
|
can_notify = await check_notification_time(session, tg_id, notification_id, hours=notification_hours)
|
||||||
if not can_notify:
|
if not can_notify:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -263,7 +299,7 @@ async def notify_10h_keys(
|
|||||||
tariff_details=expiry_data["tariff_details"],
|
tariff_details=expiry_data["tariff_details"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if NOTIFY_RENEW:
|
if notify_renew_enabled:
|
||||||
try:
|
try:
|
||||||
await process_auto_renew_or_notify(
|
await process_auto_renew_or_notify(
|
||||||
bot,
|
bot,
|
||||||
@@ -274,8 +310,8 @@ async def notify_10h_keys(
|
|||||||
"notify_10h.jpg",
|
"notify_10h.jpg",
|
||||||
notification_text,
|
notification_text,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {error}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
keyboard = build_notification_kb(email)
|
keyboard = build_notification_kb(email)
|
||||||
@@ -302,9 +338,9 @@ async def notify_10h_keys(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||||
)
|
)
|
||||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_10H_HOURS} часов.")
|
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {notification_hours} часов.")
|
||||||
|
|
||||||
logger.info(f"Обработка всех уведомлений за {NOTIFY_10H_HOURS} часов завершена.")
|
logger.info(f"Обработка всех уведомлений за {notification_hours} часов завершена.")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -323,6 +359,11 @@ async def handle_expired_keys(
|
|||||||
emails = [key.email or "" for key in expired_keys]
|
emails = [key.email or "" for key in expired_keys]
|
||||||
users = await check_notifications_bulk(session, "key_expired", 0, tg_ids=tg_ids, emails=emails)
|
users = await check_notifications_bulk(session, "key_expired", 0, tg_ids=tg_ids, emails=emails)
|
||||||
|
|
||||||
|
notify_renew_expired_enabled = bool(NOTIFICATIONS_CONFIG.get("RENEW_EXPIRED_ENABLED", NOTIFY_RENEW_EXPIRED))
|
||||||
|
notify_delete_key_enabled = bool(NOTIFICATIONS_CONFIG.get("DELETE_KEY_ENABLED", NOTIFY_DELETE_KEY))
|
||||||
|
delete_key_delay_hours = int(NOTIFICATIONS_CONFIG.get("DELETE_KEY_DELAY_HOURS", NOTIFY_DELETE_DELAY))
|
||||||
|
delete_key_delay_minutes = delete_key_delay_hours * 60
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
for key in expired_keys:
|
for key in expired_keys:
|
||||||
@@ -334,7 +375,7 @@ async def handle_expired_keys(
|
|||||||
|
|
||||||
last_notification_time = await get_last_notification_time(session, tg_id, notification_id)
|
last_notification_time = await get_last_notification_time(session, tg_id, notification_id)
|
||||||
|
|
||||||
if NOTIFY_RENEW_EXPIRED:
|
if notify_renew_expired_enabled:
|
||||||
try:
|
try:
|
||||||
balance = await get_balance(session, tg_id)
|
balance = await get_balance(session, tg_id)
|
||||||
tariffs = await get_tariffs_for_cluster(session, server_id)
|
tariffs = await get_tariffs_for_cluster(session, server_id)
|
||||||
@@ -356,19 +397,19 @@ async def handle_expired_keys(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
|
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {error}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if NOTIFY_DELETE_KEY:
|
if notify_delete_key_enabled:
|
||||||
delete_immediately = NOTIFY_DELETE_DELAY == 0
|
delete_immediately = delete_key_delay_minutes == 0
|
||||||
delete_after_delay = False
|
delete_after_delay = False
|
||||||
|
|
||||||
if last_notification_time is not None:
|
if last_notification_time is not None:
|
||||||
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
|
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= delete_key_delay_minutes
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
|
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
|
||||||
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
|
f"DELETE_KEY_DELAY_MINUTES={delete_key_delay_minutes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if delete_immediately or delete_after_delay:
|
if delete_immediately or delete_after_delay:
|
||||||
@@ -386,16 +427,16 @@ async def handle_expired_keys(
|
|||||||
"notification_id": notification_id,
|
"notification_id": notification_id,
|
||||||
"email": email,
|
"email": email,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {error}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
|
if last_notification_time is None and any(user["tg_id"] == tg_id and user["email"] == email for user in users):
|
||||||
keyboard = build_notification_kb(email)
|
keyboard = build_notification_kb(email)
|
||||||
|
|
||||||
if NOTIFY_DELETE_DELAY > 0:
|
if delete_key_delay_minutes > 0:
|
||||||
hours = NOTIFY_DELETE_DELAY // 60
|
hours = delete_key_delay_minutes // 60
|
||||||
minutes = NOTIFY_DELETE_DELAY % 60
|
minutes = delete_key_delay_minutes % 60
|
||||||
if hours > 0 and minutes > 0:
|
if hours > 0 and minutes > 0:
|
||||||
time_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
|
time_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
|
||||||
elif hours > 0:
|
elif hours > 0:
|
||||||
@@ -480,8 +521,8 @@ async def process_auto_renew_or_notify(
|
|||||||
for hook_result in hook_results:
|
for hook_result in hook_results:
|
||||||
additional_groups = hook_result.get("additional_groups", [])
|
additional_groups = hook_result.get("additional_groups", [])
|
||||||
forbidden_groups.extend(additional_groups)
|
forbidden_groups.extend(additional_groups)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
|
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||||
|
|
||||||
if current_tariff["group_code"] in forbidden_groups:
|
if current_tariff["group_code"] in forbidden_groups:
|
||||||
selected_tariff = None
|
selected_tariff = None
|
||||||
@@ -506,8 +547,8 @@ async def process_auto_renew_or_notify(
|
|||||||
for hook_result in hook_results:
|
for hook_result in hook_results:
|
||||||
additional_groups = hook_result.get("additional_groups", [])
|
additional_groups = hook_result.get("additional_groups", [])
|
||||||
forbidden_groups.extend(additional_groups)
|
forbidden_groups.extend(additional_groups)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
|
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||||
|
|
||||||
if current_tariff["group_code"] in forbidden_groups:
|
if current_tariff["group_code"] in forbidden_groups:
|
||||||
use_change_tariff_kb = True
|
use_change_tariff_kb = True
|
||||||
@@ -533,7 +574,7 @@ async def process_auto_renew_or_notify(
|
|||||||
last_notification_time = await get_last_notification_time(conn, tg_id, notification_id)
|
last_notification_time = await get_last_notification_time(conn, tg_id, notification_id)
|
||||||
if last_notification_time is not None:
|
if last_notification_time is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if use_change_tariff_kb:
|
if use_change_tariff_kb:
|
||||||
keyboard = build_change_tariff_kb(email)
|
keyboard = build_change_tariff_kb(email)
|
||||||
else:
|
else:
|
||||||
@@ -547,7 +588,6 @@ async def process_auto_renew_or_notify(
|
|||||||
client_id = key.client_id
|
client_id = key.client_id
|
||||||
current_expiry = key.expiry_time
|
current_expiry = key.expiry_time
|
||||||
duration_days = selected_tariff["duration_days"]
|
duration_days = selected_tariff["duration_days"]
|
||||||
selected_tariff["name"]
|
|
||||||
renewal_cost = selected_tariff["price_rub"]
|
renewal_cost = selected_tariff["price_rub"]
|
||||||
traffic_limit = selected_tariff["traffic_limit"]
|
traffic_limit = selected_tariff["traffic_limit"]
|
||||||
device_limit = selected_tariff["device_limit"]
|
device_limit = selected_tariff["device_limit"]
|
||||||
@@ -567,7 +607,8 @@ async def process_auto_renew_or_notify(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Продление подписки {email} на {duration_days} дней для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
f"Продление подписки {email} на {duration_days} дней для пользователя {tg_id}. "
|
||||||
|
f"Баланс: {balance}, списываем: {renewal_cost}"
|
||||||
)
|
)
|
||||||
|
|
||||||
key_subgroup = selected_tariff.get("subgroup_title")
|
key_subgroup = selected_tariff.get("subgroup_title")
|
||||||
@@ -606,5 +647,5 @@ async def process_auto_renew_or_notify(
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
|
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {error}")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import DISCOUNT_ACTIVE_HOURS, HOT_LEAD_INTERVAL_HOURS
|
from config import DISCOUNT_ACTIVE_HOURS, HOT_LEAD_INTERVAL_HOURS
|
||||||
|
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||||
from database import add_notification, check_notification_time, get_hot_leads
|
from database import add_notification, check_notification_time, get_hot_leads
|
||||||
from database.models import Notification
|
from database.models import Notification
|
||||||
from handlers.buttons import MAIN_MENU
|
from handlers.buttons import MAIN_MENU
|
||||||
@@ -21,6 +22,9 @@ from logger import logger
|
|||||||
async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||||
logger.info("Запуск уведомлений для горячих лидов.")
|
logger.info("Запуск уведомлений для горячих лидов.")
|
||||||
|
|
||||||
|
hot_lead_interval_hours = int(NOTIFICATIONS_CONFIG.get("HOT_LEADS_INTERVAL_HOURS", HOT_LEAD_INTERVAL_HOURS))
|
||||||
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
leads = await get_hot_leads(session)
|
leads = await get_hot_leads(session)
|
||||||
notified = 0
|
notified = 0
|
||||||
@@ -42,7 +46,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
|||||||
session,
|
session,
|
||||||
tg_id=tg_id,
|
tg_id=tg_id,
|
||||||
notification_type="hot_lead_step_1",
|
notification_type="hot_lead_step_1",
|
||||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
hours=hot_lead_interval_hours,
|
||||||
)
|
)
|
||||||
if not can_send:
|
if not can_send:
|
||||||
continue
|
continue
|
||||||
@@ -68,13 +72,19 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
|||||||
session,
|
session,
|
||||||
tg_id=tg_id,
|
tg_id=tg_id,
|
||||||
notification_type="hot_lead_step_2",
|
notification_type="hot_lead_step_2",
|
||||||
hours=DISCOUNT_ACTIVE_HOURS,
|
hours=discount_active_hours,
|
||||||
)
|
)
|
||||||
if expired:
|
if expired:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
result = await send_notification(bot, tg_id, None, HOT_LEAD_LOST_OPPORTUNITY, builder.as_markup())
|
result = await send_notification(
|
||||||
|
bot,
|
||||||
|
tg_id,
|
||||||
|
None,
|
||||||
|
HOT_LEAD_LOST_OPPORTUNITY,
|
||||||
|
builder.as_markup(),
|
||||||
|
)
|
||||||
if result:
|
if result:
|
||||||
await add_notification(session, tg_id, "hot_lead_step_2_expired")
|
await add_notification(session, tg_id, "hot_lead_step_2_expired")
|
||||||
logger.info(f"📭 Скидка упущена — отправлено уведомление: {tg_id}")
|
logger.info(f"📭 Скидка упущена — отправлено уведомление: {tg_id}")
|
||||||
@@ -85,7 +95,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
|||||||
session,
|
session,
|
||||||
tg_id=tg_id,
|
tg_id=tg_id,
|
||||||
notification_type="hot_lead_step_2",
|
notification_type="hot_lead_step_2",
|
||||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
hours=hot_lead_interval_hours,
|
||||||
)
|
)
|
||||||
if not can_send:
|
if not can_send:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
CONNECT_PHONE_BUTTON,
|
|
||||||
NOTIFY_EXTRA_DAYS,
|
NOTIFY_EXTRA_DAYS,
|
||||||
NOTIFY_INACTIVE,
|
NOTIFY_INACTIVE,
|
||||||
NOTIFY_INACTIVE_TRAFFIC,
|
NOTIFY_INACTIVE_TRAFFIC,
|
||||||
REMNAWAVE_WEBAPP,
|
REMNAWAVE_WEBAPP,
|
||||||
SUPPORT_CHAT_URL,
|
SUPPORT_CHAT_URL,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_notification,
|
add_notification,
|
||||||
check_notifications_bulk,
|
check_notifications_bulk,
|
||||||
@@ -22,7 +22,7 @@ from database import (
|
|||||||
update_key_notified,
|
update_key_notified,
|
||||||
)
|
)
|
||||||
from database.tariffs import get_tariffs
|
from database.tariffs import get_tariffs
|
||||||
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, TV_BUTTON
|
from handlers.buttons import CONNECT_DEVICE, MAIN_MENU
|
||||||
from handlers.keys.operations import get_user_traffic
|
from handlers.keys.operations import get_user_traffic
|
||||||
from handlers.notifications.notify_utils import send_messages_with_limit
|
from handlers.notifications.notify_utils import send_messages_with_limit
|
||||||
from handlers.texts import (
|
from handlers.texts import (
|
||||||
@@ -42,7 +42,15 @@ moscow_tz = pytz.timezone("Europe/Moscow")
|
|||||||
|
|
||||||
async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||||
users = await check_notifications_bulk(session, "inactive_trial", NOTIFY_INACTIVE)
|
|
||||||
|
inactive_hours = int(NOTIFICATIONS_CONFIG.get("INACTIVE_USER_ENABLED", NOTIFY_INACTIVE))
|
||||||
|
extra_days = int(NOTIFICATIONS_CONFIG.get("EXTRA_DAYS_AFTER_EXPIRY", NOTIFY_EXTRA_DAYS))
|
||||||
|
|
||||||
|
if inactive_hours <= 0:
|
||||||
|
logger.info("INACTIVE_USER_ENABLED <= 0, уведомления для неактивных триалов отключены.")
|
||||||
|
return
|
||||||
|
|
||||||
|
users = await check_notifications_bulk(session, "inactive_trial", inactive_hours)
|
||||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
@@ -67,11 +75,11 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
|||||||
|
|
||||||
trial_extended = user["last_notification_time"] is not None
|
trial_extended = user["last_notification_time"] is not None
|
||||||
|
|
||||||
if trial_extended:
|
if trial_extended and extra_days > 0:
|
||||||
total_days = NOTIFY_EXTRA_DAYS + trial_days
|
total_days = extra_days + trial_days
|
||||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
extra_days_formatted=format_days(extra_days),
|
||||||
total_days_formatted=format_days(total_days),
|
total_days_formatted=format_days(total_days),
|
||||||
)
|
)
|
||||||
await mark_trial_extended(tg_id, session)
|
await mark_trial_extended(tg_id, session)
|
||||||
@@ -110,6 +118,13 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
|
inactive_traffic_hours = int(NOTIFICATIONS_CONFIG.get("INACTIVE_TRAFFIC_ENABLED", NOTIFY_INACTIVE_TRAFFIC))
|
||||||
|
if inactive_traffic_hours <= 0:
|
||||||
|
logger.info("INACTIVE_TRAFFIC_ENABLED <= 0, уведомления о нулевом трафике отключены.")
|
||||||
|
return
|
||||||
|
|
||||||
|
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||||
|
|
||||||
for key in keys:
|
for key in keys:
|
||||||
tg_id = key.tg_id
|
tg_id = key.tg_id
|
||||||
email = key.email
|
email = key.email
|
||||||
@@ -122,7 +137,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
||||||
if current_dt < created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC):
|
if current_dt < created_at_dt + timedelta(hours=inactive_traffic_hours):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if expiry_time:
|
if expiry_time:
|
||||||
@@ -132,12 +147,12 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
traffic_data = await get_user_traffic(session, tg_id, email)
|
traffic_data = await get_user_traffic(session, tg_id, email)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка получения трафика для {email}: {e}")
|
logger.error(f"Ошибка получения трафика для {email}: {error}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if traffic_data.get("status") != "success":
|
if traffic_data.get("status") != "success":
|
||||||
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
logger.warning(f"Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_traffic = sum(
|
total_traffic = sum(
|
||||||
@@ -145,7 +160,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if total_traffic == 0:
|
if total_traffic == 0:
|
||||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
server_id = key.server_id
|
server_id = key.server_id
|
||||||
@@ -153,19 +168,12 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
is_full_remnawave = await is_full_remnawave_cluster(server_id, session)
|
is_full_remnawave = await is_full_remnawave_cluster(server_id, session)
|
||||||
final_link = key.key or key.remnawave_link
|
final_link = key.key or key.remnawave_link
|
||||||
|
|
||||||
if is_full_remnawave and final_link and REMNAWAVE_WEBAPP:
|
if is_full_remnawave and final_link and remnawave_webapp_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||||
else:
|
else:
|
||||||
if CONNECT_PHONE_BUTTON:
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{email}"))
|
except Exception as error:
|
||||||
builder.row(
|
logger.error(f"Ошибка при определении типа панели для {email}: {error}")
|
||||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
|
||||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при определении типа панели для {email}: {e}")
|
|
||||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
||||||
|
|
||||||
builder.row(InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
builder.row(InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||||
@@ -173,12 +181,16 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
hook_commands = await run_hooks(
|
hook_commands = await run_hooks(
|
||||||
"zero_traffic_notification", chat_id=tg_id, admin=False, session=session, email=email
|
"zero_traffic_notification",
|
||||||
|
chat_id=tg_id,
|
||||||
|
admin=False,
|
||||||
|
session=session,
|
||||||
|
email=email,
|
||||||
)
|
)
|
||||||
if hook_commands:
|
if hook_commands:
|
||||||
builder = insert_hook_buttons(builder, hook_commands)
|
builder = insert_hook_buttons(builder, hook_commands)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.warning(f"[ZERO_TRAFFIC_NOTIFICATION] Ошибка при применении хуков: {e}")
|
logger.warning(f"[ZERO_TRAFFIC_NOTIFICATION] Ошибка при применении хуков: {error}")
|
||||||
|
|
||||||
keyboard = builder.as_markup()
|
keyboard = builder.as_markup()
|
||||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||||
@@ -191,8 +203,8 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await update_key_notified(session, tg_id, client_id)
|
await update_key_notified(session, tg_id, client_id)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
|
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {error}")
|
||||||
|
|
||||||
if messages:
|
if messages:
|
||||||
results = await send_messages_with_limit(
|
results = await send_messages_with_limit(
|
||||||
@@ -205,4 +217,4 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
sent_count = sum(result for result in results if result)
|
sent_count = sum(result for result in results if result)
|
||||||
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
|
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
|
||||||
|
|
||||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
logger.info("Обработка пользователей с нулевым трафиком завершена.")
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import sqlalchemy as sa
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from config import MULTICURRENCY_ENABLE, FX_MARKUP, RUB_TO_USD
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from config import FX_MARKUP as DEFAULT_FX_MARKUP
|
||||||
|
from config import RUB_TO_USD as DEFAULT_RUB_TO_USD
|
||||||
|
from core.bootstrap import MONEY_CONFIG
|
||||||
|
|
||||||
|
|
||||||
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
|
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
|
||||||
@@ -25,8 +29,8 @@ def _round2(x: Decimal) -> Decimal:
|
|||||||
async def to_rub(amount: float | Decimal, base: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
|
async def to_rub(amount: float | Decimal, base: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
|
||||||
"""
|
"""
|
||||||
Переводит сумму ИЗ валюты base В РУБЛИ.
|
Переводит сумму ИЗ валюты base В РУБЛИ.
|
||||||
Использует get_rub_rate(base): base_per_rub, т.е. СКОЛЬКО единиц base в 1 рубле.
|
Использует get_rub_rate(base): base_per_rub, т.е. сколько единиц base в 1 рубле.
|
||||||
RUB = amount / (base_per_rub).
|
RUB = amount / base_per_rub.
|
||||||
"""
|
"""
|
||||||
rate = await get_rub_rate(base, session=session)
|
rate = await get_rub_rate(base, session=session)
|
||||||
return _q(Decimal(amount) / rate, prec=2)
|
return _q(Decimal(amount) / rate, prec=2)
|
||||||
@@ -37,8 +41,16 @@ async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = No
|
|||||||
if code == "RUB":
|
if code == "RUB":
|
||||||
return Decimal("1")
|
return Decimal("1")
|
||||||
|
|
||||||
if code == "USD" and RUB_TO_USD not in (False, None, 0):
|
rub_to_usd_cfg = MONEY_CONFIG.get("RUB_TO_USD", DEFAULT_RUB_TO_USD)
|
||||||
rate = _q(Decimal("1") / Decimal(str(RUB_TO_USD)))
|
rub_to_usd_value = 0.0
|
||||||
|
if rub_to_usd_cfg not in (False, None, 0):
|
||||||
|
try:
|
||||||
|
rub_to_usd_value = float(rub_to_usd_cfg)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rub_to_usd_value = 0.0
|
||||||
|
|
||||||
|
if code == "USD" and rub_to_usd_value > 0:
|
||||||
|
rate = _q(Decimal("1") / Decimal(str(rub_to_usd_value)))
|
||||||
cache[code] = (time.time(), rate)
|
cache[code] = (time.time(), rate)
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
@@ -68,8 +80,14 @@ async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = No
|
|||||||
rub_per_unit = Decimal(str(v["Value"])) / Decimal(str(v.get("Nominal", 1)))
|
rub_per_unit = Decimal(str(v["Value"])) / Decimal(str(v.get("Nominal", 1)))
|
||||||
rate = _q(Decimal("1") / rub_per_unit)
|
rate = _q(Decimal("1") / rub_per_unit)
|
||||||
|
|
||||||
if code != "RUB" and FX_MARKUP:
|
fx_markup_cfg = MONEY_CONFIG.get("FX_MARKUP", DEFAULT_FX_MARKUP)
|
||||||
pct = Decimal(str(FX_MARKUP)) / Decimal("100")
|
try:
|
||||||
|
fx_markup_value = Decimal(str(fx_markup_cfg))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
fx_markup_value = Decimal("0")
|
||||||
|
|
||||||
|
if code != "RUB" and fx_markup_value:
|
||||||
|
pct = fx_markup_value / Decimal("100")
|
||||||
rate = _q(rate * (Decimal("1") + pct))
|
rate = _q(rate * (Decimal("1") + pct))
|
||||||
|
|
||||||
cache[code] = (now, rate)
|
cache[code] = (now, rate)
|
||||||
@@ -80,7 +98,7 @@ async def convert_from_rub(
|
|||||||
amount_rub: Decimal | float,
|
amount_rub: Decimal | float,
|
||||||
to_ccy: str,
|
to_ccy: str,
|
||||||
*,
|
*,
|
||||||
session: aiohttp.ClientSession | None = None
|
session: aiohttp.ClientSession | None = None,
|
||||||
) -> Decimal:
|
) -> Decimal:
|
||||||
"""
|
"""
|
||||||
Конвертирует сумму из RUB в валюту to_ccy, используя get_rub_rate(to_ccy).
|
Конвертирует сумму из RUB в валюту to_ccy, используя get_rub_rate(to_ccy).
|
||||||
@@ -97,14 +115,24 @@ async def convert_from_rub(
|
|||||||
def pick_currency(
|
def pick_currency(
|
||||||
language_code: str | None,
|
language_code: str | None,
|
||||||
user_currency: str | None = None,
|
user_currency: str | None = None,
|
||||||
force_currency: str | None = None
|
force_currency: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not MULTICURRENCY_ENABLE:
|
|
||||||
return "RUB"
|
|
||||||
if force_currency in {"USD", "RUB"}:
|
if force_currency in {"USD", "RUB"}:
|
||||||
return force_currency
|
return force_currency
|
||||||
|
|
||||||
|
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
|
||||||
|
mode = str(mode_cfg or "RUB").upper()
|
||||||
|
if mode not in {"RUB", "USD", "RUB+USD"}:
|
||||||
|
mode = "RUB"
|
||||||
|
|
||||||
|
if mode == "RUB":
|
||||||
|
return "RUB"
|
||||||
|
if mode == "USD":
|
||||||
|
return "USD"
|
||||||
|
|
||||||
if user_currency in {"USD", "RUB"}:
|
if user_currency in {"USD", "RUB"}:
|
||||||
return user_currency
|
return user_currency
|
||||||
|
|
||||||
code = (language_code or "").split("-")[0].lower()
|
code = (language_code or "").split("-")[0].lower()
|
||||||
return "RUB" if code == "ru" else "USD"
|
return "RUB" if code == "ru" else "USD"
|
||||||
|
|
||||||
@@ -128,18 +156,15 @@ async def display_price(
|
|||||||
force_currency: str | None = None,
|
force_currency: str | None = None,
|
||||||
session: aiohttp.ClientSession | None = None,
|
session: aiohttp.ClientSession | None = None,
|
||||||
) -> tuple[str, str, Decimal]:
|
) -> tuple[str, str, Decimal]:
|
||||||
if force_currency in {"USD", "RUB"}:
|
cur = pick_currency(language_code, user_currency=user_currency, force_currency=force_currency)
|
||||||
cur = force_currency
|
|
||||||
if cur == "RUB":
|
|
||||||
val = _round2(Decimal(str(amount_rub)))
|
|
||||||
else:
|
|
||||||
val = await convert_from_rub(Decimal(str(amount_rub)), "USD", session=session)
|
|
||||||
txt = fmt_money(val, cur, language_code)
|
|
||||||
return txt, cur, val
|
|
||||||
|
|
||||||
val = _round2(Decimal(str(amount_rub)))
|
if cur == "RUB":
|
||||||
txt = fmt_money(val, "RUB", language_code)
|
val = _round2(Decimal(str(amount_rub)))
|
||||||
return txt, "RUB", val
|
else:
|
||||||
|
val = await convert_from_rub(Decimal(str(amount_rub)), cur, session=session)
|
||||||
|
|
||||||
|
txt = fmt_money(val, cur, language_code)
|
||||||
|
return txt, cur, val
|
||||||
|
|
||||||
|
|
||||||
async def money_for_user(
|
async def money_for_user(
|
||||||
@@ -151,9 +176,9 @@ async def money_for_user(
|
|||||||
) -> Tuple[str, str, Decimal]:
|
) -> Tuple[str, str, Decimal]:
|
||||||
"""
|
"""
|
||||||
Возвращает: (text, currency, value)
|
Возвращает: (text, currency, value)
|
||||||
- text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
|
text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
|
||||||
- currency: "USD" или "RUB"
|
currency: "USD" или "RUB"
|
||||||
- value: Decimal в выбранной валюте
|
value: Decimal в выбранной валюте
|
||||||
"""
|
"""
|
||||||
row = await db_session.execute(
|
row = await db_session.execute(
|
||||||
sa.text("select preferred_currency from users where tg_id = :id"),
|
sa.text("select preferred_currency from users where tg_id = :id"),
|
||||||
|
|||||||
@@ -5,24 +5,37 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from config import USE_NEW_PAYMENT_FLOW, MULTICURRENCY_ENABLE, PROVIDERS_ENABLED
|
from config import USE_NEW_PAYMENT_FLOW
|
||||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
|
from core.bootstrap import PAYMENTS_CONFIG, MONEY_CONFIG
|
||||||
|
|
||||||
from database.temporary_data import create_temporary_data
|
from database.temporary_data import create_temporary_data
|
||||||
from handlers import buttons as btn
|
from handlers import buttons as btn
|
||||||
from handlers.utils import edit_or_send_message
|
|
||||||
from logger import logger
|
|
||||||
|
|
||||||
from handlers.payments.currency_flow import (
|
from handlers.payments.currency_flow import (
|
||||||
build_currency_choice_kb,
|
build_currency_choice_kb,
|
||||||
shortfall_lead_text,
|
shortfall_lead_text,
|
||||||
currency_label,
|
currency_label,
|
||||||
)
|
)
|
||||||
from handlers.payments.providers import get_providers_with_hooks
|
from handlers.payments.providers import get_providers_with_hooks
|
||||||
|
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
|
||||||
|
from handlers.utils import edit_or_send_message
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_payment_providers_config() -> dict[str, bool]:
|
||||||
|
config = PAYMENTS_CONFIG or {}
|
||||||
|
return dict(config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_mode() -> str:
|
||||||
|
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
|
||||||
|
mode = str(mode_cfg or "RUB").upper()
|
||||||
|
if mode not in ("RUB", "USD", "RUB+USD"):
|
||||||
|
mode = "RUB"
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
async def _run_provider_flow(
|
async def _run_provider_flow(
|
||||||
provider: str,
|
provider: str,
|
||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
@@ -32,36 +45,37 @@ async def _run_provider_flow(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
payment_config = await get_payment_providers_config()
|
||||||
up = provider.upper()
|
providers_map = await get_providers_with_hooks(payment_config)
|
||||||
cfg = (PROVIDERS.get(up) or {})
|
provider_upper = provider.upper()
|
||||||
fast_name = cfg.get("fast")
|
cfg = providers_map.get(provider_upper) or {}
|
||||||
if not fast_name:
|
fast_handler_name = cfg.get("fast")
|
||||||
|
if not fast_handler_name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
module_name_from_config = cfg.get("module")
|
module_name_from_config = cfg.get("module")
|
||||||
if module_name_from_config:
|
if module_name_from_config:
|
||||||
module_name = f"handlers.payments.{module_name_from_config}.handlers"
|
module_name = f"handlers.payments.{module_name_from_config}.handlers"
|
||||||
else:
|
else:
|
||||||
module_name = f"handlers.payments.{up.lower()}.handlers"
|
module_name = f"handlers.payments.{provider_upper.lower()}.handlers"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(module_name)
|
module = importlib.import_module(module_name)
|
||||||
func = getattr(module, fast_name)
|
func = getattr(module, fast_handler_name)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"[FAST_FLOW] Импорт {up}.{fast_name} из {module_name} не удался: {e}")
|
logger.error(f"[FAST_FLOW] Импорт {provider_upper}.{fast_handler_name} из {module_name} не удался: {error}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if up == "STARS":
|
if provider_upper == "STARS":
|
||||||
try:
|
try:
|
||||||
await callback_query.message.delete()
|
await callback_query.message.delete()
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {e}")
|
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {error}")
|
||||||
await func(callback_query, session)
|
await func(callback_query, session)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"[FAST_FLOW] Ошибка при вызове {up}.{fast_name}(): {e}")
|
logger.error(f"[FAST_FLOW] Ошибка при вызове {provider_upper}.{fast_handler_name}(): {error}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -80,137 +94,170 @@ async def try_fast_payment_flow(
|
|||||||
if not USE_NEW_PAYMENT_FLOW:
|
if not USE_NEW_PAYMENT_FLOW:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
payment_config = await get_payment_providers_config()
|
||||||
|
providers_map = await get_providers_with_hooks(payment_config)
|
||||||
|
|
||||||
providers = (
|
providers = (
|
||||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
[USE_NEW_PAYMENT_FLOW]
|
||||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||||
|
else [str(provider) for provider in (USE_NEW_PAYMENT_FLOW or [])]
|
||||||
)
|
)
|
||||||
providers = [
|
providers = [
|
||||||
p for p in providers
|
provider
|
||||||
if (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
for provider in providers
|
||||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
if (providers_map.get(str(provider).upper()) or {}).get("fast")
|
||||||
|
and (providers_map.get(str(provider).upper()) or {}).get("enabled", True)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
mode = get_currency_mode()
|
||||||
|
multicurrency_enabled = mode == "RUB+USD"
|
||||||
|
|
||||||
|
if not multicurrency_enabled:
|
||||||
|
allowed_currency = "RUB" if mode == "RUB" else "USD"
|
||||||
|
providers = [
|
||||||
|
provider
|
||||||
|
for provider in providers
|
||||||
|
if (providers_map.get(provider.upper()) or {}).get("currency") in (allowed_currency, "RUB+USD")
|
||||||
|
]
|
||||||
|
|
||||||
if not providers:
|
if not providers:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if len(providers) == 1:
|
if len(providers) == 1 and not multicurrency_enabled:
|
||||||
up = providers[0].upper()
|
single_provider = providers[0].upper()
|
||||||
cfg = PROVIDERS.get(up) or {}
|
cfg = providers_map.get(single_provider) or {}
|
||||||
currency = cfg.get("currency")
|
currency = cfg.get("currency")
|
||||||
if currency:
|
if currency:
|
||||||
await state.update_data(chosen_currency=currency)
|
await state.update_data(chosen_currency=currency)
|
||||||
if await _run_provider_flow(up, callback_query, session, state, required_amount):
|
if await _run_provider_flow(single_provider, callback_query, session, state, required_amount):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if MULTICURRENCY_ENABLE:
|
if multicurrency_enabled:
|
||||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
show_stars = bool((providers_map.get("STARS") or {}).get("enabled"))
|
||||||
show_tribute = bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
show_tribute = bool((providers_map.get("TRIBUTE") or {}).get("enabled"))
|
||||||
kb = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
|
keyboard = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
|
||||||
lead = await shortfall_lead_text(
|
lead_text = await shortfall_lead_text(
|
||||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
session,
|
||||||
|
tg_id,
|
||||||
|
required_amount,
|
||||||
|
getattr(callback_query.from_user, "language_code", None),
|
||||||
)
|
)
|
||||||
text = f"{lead}.\n\n{FAST_PAY_CHOOSE_CURRENCY}"
|
text = f"{lead_text}.\n\n{FAST_PAY_CHOOSE_CURRENCY}"
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
temp_key=temp_key,
|
temp_key=temp_key,
|
||||||
temp_payload=temp_payload,
|
temp_payload=temp_payload,
|
||||||
required_amount=required_amount,
|
required_amount=required_amount,
|
||||||
fastflow_providers=providers,
|
fastflow_providers=providers,
|
||||||
)
|
)
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text,
|
||||||
|
reply_markup=keyboard.as_markup(),
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
kb = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
for p in providers:
|
for provider in providers:
|
||||||
up = p.upper()
|
provider_upper = provider.upper()
|
||||||
btn_text = getattr(btn, up, up)
|
button_text = getattr(btn, provider_upper, provider_upper)
|
||||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{up}"))
|
keyboard.row(InlineKeyboardButton(text=button_text, callback_data=f"choose_payment_provider|{provider_upper}"))
|
||||||
|
|
||||||
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
lead = await shortfall_lead_text(
|
lead_text = await shortfall_lead_text(
|
||||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
session,
|
||||||
|
tg_id,
|
||||||
|
required_amount,
|
||||||
|
getattr(callback_query.from_user, "language_code", None),
|
||||||
)
|
)
|
||||||
await state.update_data(temp_key=temp_key, temp_payload=temp_payload, required_amount=required_amount)
|
await state.update_data(temp_key=temp_key, temp_payload=temp_payload, required_amount=required_amount)
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text=f"{lead}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
text=f"{lead_text}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
||||||
reply_markup=kb.as_markup(),
|
reply_markup=keyboard.as_markup(),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("choose_payment_currency|"))
|
@router.callback_query(F.data.startswith("choose_payment_currency|"))
|
||||||
async def choose_payment_currency(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
async def choose_payment_currency(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
payment_config = await get_payment_providers_config()
|
||||||
|
providers_map = await get_providers_with_hooks(payment_config)
|
||||||
|
|
||||||
currency = callback_query.data.split("|")[1]
|
currency = callback_query.data.split("|")[1]
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
|
|
||||||
providers = data.get("fastflow_providers") or (
|
providers = data.get("fastflow_providers") or (
|
||||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
[USE_NEW_PAYMENT_FLOW]
|
||||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||||
|
else [str(provider) for provider in (USE_NEW_PAYMENT_FLOW or [])]
|
||||||
)
|
)
|
||||||
filtered = [
|
filtered = [
|
||||||
p.upper() for p in providers
|
provider_upper
|
||||||
if (PROVIDERS.get(str(p).upper()) or {}).get("currency") == currency
|
for provider_upper in (provider.upper() for provider in providers)
|
||||||
and (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
if (providers_map.get(provider_upper) or {}).get("currency") == currency
|
||||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
and (providers_map.get(provider_upper) or {}).get("fast")
|
||||||
|
and (providers_map.get(provider_upper) or {}).get("enabled", True)
|
||||||
]
|
]
|
||||||
await state.update_data(chosen_currency=currency)
|
await state.update_data(chosen_currency=currency)
|
||||||
|
|
||||||
if not filtered:
|
if not filtered:
|
||||||
kb = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
keyboard = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
|
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
|
||||||
reply_markup=kb.as_markup(),
|
reply_markup=keyboard.as_markup(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if len(filtered) == 1:
|
if len(filtered) == 1:
|
||||||
only = filtered[0]
|
only_provider = filtered[0]
|
||||||
if await _run_provider_flow(only, callback_query, session, state, data.get("required_amount")):
|
if await _run_provider_flow(only_provider, callback_query, session, state, data.get("required_amount")):
|
||||||
return
|
return
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text="Этот способ временно недоступен.",
|
text="Этот способ временно недоступен.",
|
||||||
reply_markup=InlineKeyboardBuilder().row(
|
reply_markup=InlineKeyboardBuilder()
|
||||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
).as_markup(),
|
.as_markup(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
kb = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
for p in filtered:
|
for provider_upper in filtered:
|
||||||
btn_text = getattr(btn, p, p)
|
button_text = getattr(btn, provider_upper, provider_upper)
|
||||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{p}"))
|
keyboard.row(
|
||||||
|
InlineKeyboardButton(text=button_text, callback_data=f"choose_payment_provider|{provider_upper}")
|
||||||
|
)
|
||||||
|
|
||||||
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
lead = await shortfall_lead_text(
|
lead_text = await shortfall_lead_text(
|
||||||
session,
|
session,
|
||||||
callback_query.from_user.id,
|
callback_query.from_user.id,
|
||||||
data.get("required_amount"),
|
data.get("required_amount"),
|
||||||
getattr(callback_query.from_user, "language_code", None),
|
getattr(callback_query.from_user, "language_code", None),
|
||||||
force_currency=currency,
|
force_currency=currency,
|
||||||
)
|
)
|
||||||
text = f"{lead}.\n\nВалюта: {currency_label(currency)}\n{FAST_PAY_CHOOSE_PROVIDER}"
|
text = f"{lead_text}.\n\nВалюта: {currency_label(currency)}\n{FAST_PAY_CHOOSE_PROVIDER}"
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
|
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=keyboard.as_markup())
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("choose_payment_provider|"))
|
@router.callback_query(F.data.startswith("choose_payment_provider|"))
|
||||||
async def choose_payment_provider(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
async def choose_payment_provider(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
payment_config = await get_payment_providers_config()
|
||||||
|
providers_map = await get_providers_with_hooks(payment_config)
|
||||||
|
|
||||||
provider = callback_query.data.split("|")[1].upper()
|
provider = callback_query.data.split("|")[1].upper()
|
||||||
cfg = PROVIDERS.get(provider) or {}
|
cfg = providers_map.get(provider) or {}
|
||||||
if not cfg.get("fast") or not cfg.get("enabled", True):
|
if not cfg.get("fast") or not cfg.get("enabled", True):
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text="Этот способ временно недоступен.",
|
text="Этот способ временно недоступен.",
|
||||||
reply_markup=InlineKeyboardBuilder().row(
|
reply_markup=InlineKeyboardBuilder()
|
||||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
).as_markup(),
|
.as_markup(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+111
-44
@@ -8,87 +8,129 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import MULTICURRENCY_ENABLE, DONATIONS_ENABLE, PROVIDERS_ENABLED
|
from config import DONATIONS_ENABLE
|
||||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
from core.bootstrap import PAYMENTS_CONFIG, MONEY_CONFIG, BUTTONS_CONFIG
|
||||||
|
|
||||||
from database import get_last_payments
|
from database import get_last_payments
|
||||||
from database.models import User
|
from database.models import User
|
||||||
from handlers import buttons as btn
|
from handlers import buttons as btn
|
||||||
|
from handlers.payments.currency_flow import build_currency_choice_kb
|
||||||
|
from handlers.payments.currency_rates import format_for_user
|
||||||
|
from handlers.payments.providers import get_providers_with_hooks
|
||||||
from handlers.payments.stars.handlers import process_callback_pay_stars
|
from handlers.payments.stars.handlers import process_callback_pay_stars
|
||||||
from handlers.payments.tribute.handlers import process_callback_pay_tribute
|
from handlers.payments.tribute.handlers import process_callback_pay_tribute
|
||||||
|
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||||
from hooks.hook_buttons import insert_hook_buttons
|
from hooks.hook_buttons import insert_hook_buttons
|
||||||
from hooks.hooks import run_hooks
|
from hooks.hooks import run_hooks
|
||||||
from handlers.payments.currency_rates import format_for_user
|
|
||||||
from handlers.payments.currency_flow import build_currency_choice_kb
|
|
||||||
from handlers.payments.providers import get_providers_with_hooks
|
|
||||||
|
|
||||||
from ..utils import edit_or_send_message
|
from ..utils import edit_or_send_message
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_payment_providers_config() -> dict[str, bool]:
|
||||||
|
config = PAYMENTS_CONFIG or {}
|
||||||
|
return dict(config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_mode() -> str:
|
||||||
|
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
|
||||||
|
mode = str(mode_cfg or "RUB").upper()
|
||||||
|
if mode not in ("RUB", "USD", "RUB+USD"):
|
||||||
|
mode = "RUB"
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pay")
|
@router.callback_query(F.data == "pay")
|
||||||
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
providers_config = await get_payment_providers_config()
|
||||||
|
providers_with_hooks = await get_providers_with_hooks(providers_config)
|
||||||
|
|
||||||
|
mode = get_currency_mode()
|
||||||
|
multicurrency_enabled = mode == "RUB+USD"
|
||||||
|
|
||||||
|
if not multicurrency_enabled:
|
||||||
|
allowed_currency = "RUB" if mode == "RUB" else "USD"
|
||||||
|
filtered: dict[str, dict[str, Any]] = {}
|
||||||
|
for key, cfg in providers_with_hooks.items():
|
||||||
|
currency = str(cfg.get("currency") or "").upper()
|
||||||
|
if currency in (allowed_currency, "RUB+USD"):
|
||||||
|
filtered[key] = cfg
|
||||||
|
providers_with_hooks = filtered
|
||||||
|
|
||||||
payment_handlers = []
|
payment_handlers = []
|
||||||
|
|
||||||
for key, cfg in PROVIDERS.items():
|
for key, cfg in providers_with_hooks.items():
|
||||||
if not cfg.get("enabled"):
|
if not cfg.get("enabled"):
|
||||||
continue
|
continue
|
||||||
val = cfg.get("value")
|
handler_callback_data = cfg.get("value")
|
||||||
if not val:
|
if not handler_callback_data:
|
||||||
continue
|
continue
|
||||||
fn = globals().get(f"process_callback_{val}")
|
handler = globals().get(f"process_callback_{handler_callback_data}")
|
||||||
if callable(fn):
|
if callable(handler):
|
||||||
payment_handlers.append(fn)
|
payment_handlers.append(handler)
|
||||||
|
|
||||||
module_buttons = await run_hooks("pay_menu_buttons", chat_id=callback_query.from_user.id, admin=False, session=session)
|
module_buttons = await run_hooks(
|
||||||
has_extra_menu_items = bool(module_buttons) or bool(DONATIONS_ENABLE) or bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
"pay_menu_buttons",
|
||||||
|
chat_id=callback_query.from_user.id,
|
||||||
|
admin=False,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
if MULTICURRENCY_ENABLE:
|
donations_enabled = bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE))
|
||||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
has_extra_menu_items = bool(module_buttons) or donations_enabled or bool(
|
||||||
show_tribute = bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
(providers_with_hooks.get("TRIBUTE") or {}).get("enabled")
|
||||||
kb = build_currency_choice_kb(show_stars=show_stars, prefix="pay_currency", show_tribute=show_tribute)
|
)
|
||||||
|
|
||||||
|
if multicurrency_enabled:
|
||||||
|
show_stars = bool((providers_with_hooks.get("STARS") or {}).get("enabled"))
|
||||||
|
show_tribute = bool((providers_with_hooks.get("TRIBUTE") or {}).get("enabled"))
|
||||||
|
keyboard = build_currency_choice_kb(show_stars=show_stars, prefix="pay_currency", show_tribute=show_tribute)
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text=FAST_PAY_CHOOSE_CURRENCY,
|
text=FAST_PAY_CHOOSE_CURRENCY,
|
||||||
reply_markup=kb.as_markup()
|
reply_markup=keyboard.as_markup(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not has_extra_menu_items:
|
if not has_extra_menu_items:
|
||||||
enabled_providers_count = sum(1 for _k, _cfg in PROVIDERS.items() if _cfg.get("enabled"))
|
enabled_providers_count = sum(1 for _, cfg in providers_with_hooks.items() if cfg.get("enabled"))
|
||||||
if enabled_providers_count == 1 and len(payment_handlers) == 1:
|
if enabled_providers_count == 1 and len(payment_handlers) == 1:
|
||||||
return await payment_handlers[0](callback_query, state, session)
|
return await payment_handlers[0](callback_query, state, session)
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
for key, cfg in PROVIDERS.items():
|
for key, cfg in providers_with_hooks.items():
|
||||||
if not cfg.get("enabled"):
|
if not cfg.get("enabled"):
|
||||||
continue
|
continue
|
||||||
text = getattr(btn, key, key)
|
text = getattr(btn, key, key)
|
||||||
builder.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
builder.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||||
|
|
||||||
if DONATIONS_ENABLE:
|
if donations_enabled:
|
||||||
builder.row(InlineKeyboardButton(text=btn.DONAT_BUTTON, callback_data="donate"))
|
builder.row(InlineKeyboardButton(text=btn.DONAT_BUTTON, callback_data="donate"))
|
||||||
|
|
||||||
builder = insert_hook_buttons(builder, module_buttons)
|
builder = insert_hook_buttons(builder, module_buttons)
|
||||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=PAYMENT_METHODS_MSG, reply_markup=builder.as_markup())
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=PAYMENT_METHODS_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_pay_menu_for_currency(currency: str) -> InlineKeyboardBuilder:
|
async def _build_pay_menu_for_currency(currency: str) -> InlineKeyboardBuilder:
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
providers_config = await get_payment_providers_config()
|
||||||
b = InlineKeyboardBuilder()
|
providers_with_hooks = await get_providers_with_hooks(providers_config)
|
||||||
for key, cfg in PROVIDERS.items():
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for key, cfg in providers_with_hooks.items():
|
||||||
if not cfg.get("enabled"):
|
if not cfg.get("enabled"):
|
||||||
continue
|
continue
|
||||||
if cfg.get("currency") != currency:
|
if cfg.get("currency") != currency:
|
||||||
continue
|
continue
|
||||||
text = getattr(btn, key, key)
|
text = getattr(btn, key, key)
|
||||||
b.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
builder.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||||
return b
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("pay_currency|"))
|
@router.callback_query(F.data.startswith("pay_currency|"))
|
||||||
@@ -99,16 +141,26 @@ async def handle_pay_currency(callback_query: CallbackQuery, state: FSMContext,
|
|||||||
return await process_callback_pay_stars(callback_query, state, session)
|
return await process_callback_pay_stars(callback_query, state, session)
|
||||||
|
|
||||||
base_builder = await _build_pay_menu_for_currency(currency)
|
base_builder = await _build_pay_menu_for_currency(currency)
|
||||||
module_buttons = await run_hooks("pay_menu_buttons", chat_id=callback_query.from_user.id, admin=False, session=session)
|
module_buttons = await run_hooks(
|
||||||
|
"pay_menu_buttons",
|
||||||
|
chat_id=callback_query.from_user.id,
|
||||||
|
admin=False,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
builder = insert_hook_buttons(base_builder, module_buttons)
|
builder = insert_hook_buttons(base_builder, module_buttons)
|
||||||
|
|
||||||
if DONATIONS_ENABLE:
|
donations_enabled = bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE))
|
||||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
if donations_enabled:
|
||||||
|
builder.row(InlineKeyboardButton(text=btn.DONAT_BUTTON, callback_data="donate"))
|
||||||
|
|
||||||
builder.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_currency"))
|
builder.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_currency"))
|
||||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=PAYMENT_METHODS_MSG, reply_markup=builder.as_markup())
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=PAYMENT_METHODS_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "balance")
|
@router.callback_query(F.data == "balance")
|
||||||
@@ -129,7 +181,13 @@ async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
|
|||||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance_text)
|
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance_text)
|
||||||
image_path = os.path.join("img", "pay.jpg")
|
image_path = os.path.join("img", "pay.jpg")
|
||||||
|
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=builder.as_markup(), media_path=image_path, disable_web_page_preview=False)
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=image_path,
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "balance_history")
|
@router.callback_query(F.data == "balance_history")
|
||||||
@@ -149,25 +207,35 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
|||||||
payment_system = record["payment_system"]
|
payment_system = record["payment_system"]
|
||||||
status = record["status"]
|
status = record["status"]
|
||||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||||
history_text += f"Сумма: {formatted_amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
history_text += (
|
||||||
|
f"Сумма: {formatted_amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
||||||
|
)
|
||||||
history_text += "</blockquote>"
|
history_text += "</blockquote>"
|
||||||
else:
|
else:
|
||||||
history_text = "❌ У вас пока нет операций с балансом."
|
history_text = "❌ У вас пока нет операций с балансом."
|
||||||
|
|
||||||
await edit_or_send_message(target_message=callback_query.message, text=history_text, reply_markup=builder.as_markup(), media_path=None, disable_web_page_preview=False)
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=history_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "back_to_currency")
|
@router.callback_query(F.data == "back_to_currency")
|
||||||
async def back_to_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
async def back_to_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
providers_config = await get_payment_providers_config()
|
||||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
providers_with_hooks = await get_providers_with_hooks(providers_config)
|
||||||
show_tribute = bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
|
||||||
kb = build_currency_choice_kb(show_stars=show_stars, prefix="pay_currency", show_tribute=show_tribute)
|
show_stars = bool((providers_with_hooks.get("STARS") or {}).get("enabled"))
|
||||||
kb.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_pay"))
|
show_tribute = bool((providers_with_hooks.get("TRIBUTE") or {}).get("enabled"))
|
||||||
|
keyboard = build_currency_choice_kb(show_stars=show_stars, prefix="pay_currency", show_tribute=show_tribute)
|
||||||
|
keyboard.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_pay"))
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
target_message=callback_query.message,
|
target_message=callback_query.message,
|
||||||
text=FAST_PAY_CHOOSE_CURRENCY,
|
text=FAST_PAY_CHOOSE_CURRENCY,
|
||||||
reply_markup=kb.as_markup()
|
reply_markup=keyboard.as_markup(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,7 +244,6 @@ async def back_to_pay(callback_query: CallbackQuery, state: FSMContext, session:
|
|||||||
return await balance_handler(callback_query, session)
|
return await balance_handler(callback_query, session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pay_tribute")
|
@router.callback_query(F.data == "pay_tribute")
|
||||||
async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||||
await process_callback_pay_tribute(callback_query, state, session)
|
await process_callback_pay_tribute(callback_query, state, session)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Dict
|
from typing import Any
|
||||||
|
|
||||||
from hooks.hooks import run_hooks
|
from hooks.hooks import run_hooks
|
||||||
|
|
||||||
PROVIDERS_BASE: Dict[str, dict] = {
|
|
||||||
|
PROVIDERS_BASE: dict[str, dict[str, Any]] = {
|
||||||
"YOOKASSA": {
|
"YOOKASSA": {
|
||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"value": "pay_yookassa",
|
"value": "pay_yookassa",
|
||||||
@@ -30,7 +32,7 @@ PROVIDERS_BASE: Dict[str, dict] = {
|
|||||||
"module": "kassai",
|
"module": "kassai",
|
||||||
},
|
},
|
||||||
"TRIBUTE": {
|
"TRIBUTE": {
|
||||||
"currency": "RUB",
|
"currency": "RUB+USD",
|
||||||
"value": "pay_tribute",
|
"value": "pay_tribute",
|
||||||
"fast": None,
|
"fast": None,
|
||||||
},
|
},
|
||||||
@@ -56,25 +58,27 @@ PROVIDERS_BASE: Dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_providers(flags: Dict[str, bool]) -> Dict[str, dict]:
|
|
||||||
out: Dict[str, dict] = {}
|
|
||||||
for k, base in PROVIDERS_BASE.items():
|
|
||||||
cfg = dict(base)
|
|
||||||
cfg["enabled"] = bool(flags.get(k))
|
|
||||||
out[k] = cfg
|
|
||||||
return out
|
|
||||||
|
|
||||||
async def get_providers_with_hooks(flags: Dict[str, bool]) -> Dict[str, dict]:
|
def get_providers(flags: dict[str, bool]) -> dict[str, dict[str, Any]]:
|
||||||
out = get_providers(flags)
|
providers: dict[str, dict[str, Any]] = {}
|
||||||
results = await run_hooks("providers_config", providers=out, flags=flags)
|
for name, base in PROVIDERS_BASE.items():
|
||||||
for r in results:
|
cfg = dict(base)
|
||||||
if not isinstance(r, dict):
|
cfg["enabled"] = bool(flags.get(name))
|
||||||
|
providers[name] = cfg
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
async def get_providers_with_hooks(flags: dict[str, bool]) -> dict[str, dict[str, Any]]:
|
||||||
|
providers = get_providers(flags)
|
||||||
|
results = await run_hooks("providers_config", providers=providers, flags=flags)
|
||||||
|
for result in results:
|
||||||
|
if not isinstance(result, dict):
|
||||||
continue
|
continue
|
||||||
for name, patch in r.items():
|
for name, patch in result.items():
|
||||||
if patch is None:
|
if patch is None:
|
||||||
out.pop(name, None)
|
providers.pop(name, None)
|
||||||
elif isinstance(patch, dict):
|
elif isinstance(patch, dict):
|
||||||
base = dict(out.get(name, {}))
|
base = dict(providers.get(name, {}))
|
||||||
base.update(patch)
|
base.update(patch)
|
||||||
out[name] = base
|
providers[name] = base
|
||||||
return out
|
return providers
|
||||||
|
|||||||
Binary file not shown.
+36
-17
@@ -15,6 +15,7 @@ from config import (
|
|||||||
SHOW_START_MENU_ONCE,
|
SHOW_START_MENU_ONCE,
|
||||||
TRIAL_TIME_DISABLE,
|
TRIAL_TIME_DISABLE,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||||
from database import get_balance, get_key_count, get_trial
|
from database import get_balance, get_key_count, get_trial
|
||||||
from handlers.buttons import (
|
from handlers.buttons import (
|
||||||
ABOUT_VPN,
|
ABOUT_VPN,
|
||||||
@@ -68,9 +69,18 @@ async def process_callback_view_profile(
|
|||||||
)
|
)
|
||||||
balance_rub = balance_rub or 0
|
balance_rub = balance_rub or 0
|
||||||
|
|
||||||
fmt_task = asyncio.create_task(format_for_user(session, chat_id, balance_rub, getattr(user, "language_code", None)))
|
balance_text_task = asyncio.create_task(
|
||||||
profile_menu_task = asyncio.create_task(run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session))
|
format_for_user(
|
||||||
profile_text_task = asyncio.create_task(
|
session,
|
||||||
|
chat_id,
|
||||||
|
balance_rub,
|
||||||
|
getattr(user, "language_code", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
profile_menu_buttons_task = asyncio.create_task(
|
||||||
|
run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session)
|
||||||
|
)
|
||||||
|
profile_text_hooks_task = asyncio.create_task(
|
||||||
run_hooks(
|
run_hooks(
|
||||||
"profile_text",
|
"profile_text",
|
||||||
username=username,
|
username=username,
|
||||||
@@ -81,48 +91,57 @@ async def process_callback_view_profile(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
balance_text = await fmt_task
|
balance_text = await balance_text_task
|
||||||
|
|
||||||
profile_message = profile_message_send(username, chat_id, balance_text, key_count)
|
profile_message = profile_message_send(username, chat_id, balance_text, key_count)
|
||||||
profile_message += ADD_SUBSCRIPTION_HINT if key_count == 0 else f"\n<blockquote><i>{NEWS_MESSAGE}</i></blockquote>"
|
if key_count == 0:
|
||||||
|
profile_message += ADD_SUBSCRIPTION_HINT
|
||||||
|
else:
|
||||||
|
profile_message += f"\n<blockquote><i>{NEWS_MESSAGE}</i></blockquote>"
|
||||||
|
|
||||||
text_hooks = await profile_text_task
|
text_hooks = await profile_text_hooks_task
|
||||||
if text_hooks:
|
if text_hooks:
|
||||||
profile_message = text_hooks[0]
|
profile_message = text_hooks[0]
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
trial_time_disabled = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||||
|
|
||||||
if key_count > 0:
|
if key_count > 0:
|
||||||
subs_label = MY_SUB if key_count == 1 else MY_SUBS
|
subscriptions_button_text = MY_SUB if key_count == 1 else MY_SUBS
|
||||||
builder.row(InlineKeyboardButton(text=subs_label, callback_data="view_keys"))
|
builder.row(InlineKeyboardButton(text=subscriptions_button_text, callback_data="view_keys"))
|
||||||
elif trial_status == 0 and not TRIAL_TIME_DISABLE:
|
elif trial_status == 0 and not trial_time_disabled:
|
||||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||||
|
|
||||||
if BALANCE_BUTTON:
|
if BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON):
|
||||||
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
|
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
|
||||||
|
|
||||||
extra_buttons = []
|
extra_buttons = []
|
||||||
if REFERRAL_BUTTON:
|
if BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLE", REFERRAL_BUTTON):
|
||||||
extra_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
|
extra_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
|
||||||
if GIFT_BUTTON:
|
if BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON):
|
||||||
extra_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
|
extra_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
|
||||||
if extra_buttons:
|
if extra_buttons:
|
||||||
builder.row(*extra_buttons)
|
builder.row(*extra_buttons)
|
||||||
|
|
||||||
module_buttons = await profile_menu_task
|
profile_menu_buttons = await profile_menu_buttons_task
|
||||||
builder = insert_hook_buttons(builder, module_buttons)
|
builder = insert_hook_buttons(builder, profile_menu_buttons)
|
||||||
|
|
||||||
if INSTRUCTIONS_BUTTON:
|
if BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON):
|
||||||
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
|
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
|
||||||
|
|
||||||
if admin:
|
if admin:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
InlineKeyboardButton(
|
||||||
|
text="📊 Администратор",
|
||||||
|
callback_data=AdminPanelCallback(action="admin").pack(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if SHOW_START_MENU_ONCE:
|
show_start_menu_once = bool(MODES_CONFIG.get("SHOW_START_MENU_ONLY_ONCE", SHOW_START_MENU_ONCE))
|
||||||
|
if show_start_menu_once:
|
||||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||||
else:
|
else:
|
||||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
||||||
|
|||||||
+31
-25
@@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot import bot
|
from bot import bot
|
||||||
from config import ADMIN_ID, INLINE_MODE, REFERRAL_BONUS_PERCENTAGES, REFERRAL_QR, TOP_REFERRAL_BUTTON, USERNAME_BOT
|
from config import ADMIN_ID, INLINE_MODE, REFERRAL_BONUS_PERCENTAGES, REFERRAL_QR, TOP_REFERRAL_BUTTON, USERNAME_BOT
|
||||||
|
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_referral,
|
add_referral,
|
||||||
add_user,
|
add_user,
|
||||||
@@ -69,40 +70,42 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery, ses
|
|||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
bonuses_lines.append(f"{level} уровень: 🌟 {int(value * 100)}% бонуса")
|
bonuses_lines.append(f"{level} уровень: 🌟 {int(value * 100)}% бонуса")
|
||||||
else:
|
else:
|
||||||
value_txt = await format_for_user(session, chat_id, value, language_code)
|
value_text = await format_for_user(session, chat_id, value, language_code)
|
||||||
bonuses_lines.append(f"{level} уровень: 💸 {value_txt} бонуса")
|
bonuses_lines.append(f"{level} уровень: 💸 {value_text} бонуса")
|
||||||
bonuses_block = "\n".join(bonuses_lines)
|
bonuses_block = "\n".join(bonuses_lines)
|
||||||
|
|
||||||
details_lines = []
|
details_lines = []
|
||||||
for level, stats in referral_stats["referrals_by_level"].items():
|
for level, stats in referral_stats["referrals_by_level"].items():
|
||||||
bonus_value = REFERRAL_BONUS_PERCENTAGES.get(level)
|
bonus_value = REFERRAL_BONUS_PERCENTAGES.get(level)
|
||||||
if isinstance(bonus_value, float):
|
if isinstance(bonus_value, float):
|
||||||
bonus_str = f"{int(bonus_value * 100)}%"
|
bonus_text = f"{int(bonus_value * 100)}%"
|
||||||
else:
|
else:
|
||||||
bonus_str = await format_for_user(session, chat_id, bonus_value, language_code)
|
bonus_text = await format_for_user(session, chat_id, bonus_value, language_code)
|
||||||
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_str}")
|
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_text}")
|
||||||
details_block = "\n".join(details_lines)
|
details_block = "\n".join(details_lines)
|
||||||
|
|
||||||
total_bonus_txt = await format_for_user(session, chat_id, referral_stats["total_referral_bonus"], language_code)
|
total_bonus_text = await format_for_user(session, chat_id, referral_stats["total_referral_bonus"], language_code)
|
||||||
|
|
||||||
invite_message = INVITE_MESSAGE_TEMPLATE.format(
|
invite_message = INVITE_MESSAGE_TEMPLATE.format(
|
||||||
referral_link=referral_link,
|
referral_link=referral_link,
|
||||||
bonuses_block=bonuses_block,
|
bonuses_block=bonuses_block,
|
||||||
total_referrals=referral_stats["total_referrals"],
|
total_referrals=referral_stats["total_referrals"],
|
||||||
details_block=details_block,
|
details_block=details_block,
|
||||||
total_referral_bonus=total_bonus_txt,
|
total_referral_bonus=total_bonus_text,
|
||||||
)
|
)
|
||||||
image_path = os.path.join("img", "pic_invite.jpg")
|
image_path = os.path.join("img", "pic_invite.jpg")
|
||||||
|
|
||||||
|
inline_mode_enabled = bool(MODES_CONFIG.get("INLINE_MODE_ENABLED", INLINE_MODE))
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
if INLINE_MODE:
|
if inline_mode_enabled:
|
||||||
builder.button(text=INVITE, switch_inline_query="invite")
|
builder.button(text=INVITE, switch_inline_query="invite")
|
||||||
else:
|
else:
|
||||||
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
|
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
|
||||||
builder.button(text=INVITE, switch_inline_query=invite_text)
|
builder.button(text=INVITE, switch_inline_query=invite_text)
|
||||||
if REFERRAL_QR:
|
if BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR):
|
||||||
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
|
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
|
||||||
if TOP_REFERRAL_BUTTON:
|
if BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON):
|
||||||
builder.button(text=TOP_FIVE, callback_data="top_referrals")
|
builder.button(text=TOP_FIVE, callback_data="top_referrals")
|
||||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
@@ -143,7 +146,10 @@ async def inline_referral_handler(inline_query: InlineQuery, session: AsyncSessi
|
|||||||
id=str(index),
|
id=str(index),
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
input_message_content=InputTextMessageContent(
|
||||||
|
message_text=message_text,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
),
|
||||||
reply_markup=builder.as_markup(),
|
reply_markup=builder.as_markup(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -161,14 +167,14 @@ async def show_referral_qr(callback_query: CallbackQuery):
|
|||||||
qr.add_data(referral_link)
|
qr.add_data(referral_link)
|
||||||
qr.make(fit=True)
|
qr.make(fit=True)
|
||||||
|
|
||||||
img = qr.make_image(fill_color="black", back_color="white")
|
image = qr.make_image(fill_color="black", back_color="white")
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
img.save(buffer, format="PNG")
|
image.save(buffer, format="PNG")
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
|
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
|
||||||
with open(qr_path, "wb") as f:
|
with open(qr_path, "wb") as file:
|
||||||
f.write(buffer.read())
|
file.write(buffer.read())
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||||
@@ -183,8 +189,8 @@ async def show_referral_qr(callback_query: CallbackQuery):
|
|||||||
|
|
||||||
os.remove(qr_path)
|
os.remove(qr_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
|
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {error}", exc_info=True)
|
||||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||||
|
|
||||||
|
|
||||||
@@ -223,11 +229,11 @@ async def top_referrals_handler(callback_query: CallbackQuery, session: AsyncSes
|
|||||||
|
|
||||||
is_admin = user_id in ADMIN_ID
|
is_admin = user_id in ADMIN_ID
|
||||||
rows = ""
|
rows = ""
|
||||||
for i, row in enumerate(top_referrals, 1):
|
for index, row in enumerate(top_referrals, 1):
|
||||||
tg_id = str(row.referrer_tg_id)
|
referrer_id = str(row.referrer_tg_id)
|
||||||
count = row.referral_count
|
count = row.referral_count
|
||||||
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
|
display_id = referrer_id if is_admin else f"{referrer_id[:5]}*****"
|
||||||
rows += f"{i}. {display_id} - {count} чел.\n"
|
rows += f"{index}. {display_id} - {count} чел.\n"
|
||||||
|
|
||||||
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
|
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
|
||||||
|
|
||||||
@@ -290,11 +296,11 @@ async def handle_referral_link(
|
|||||||
referrer_tg_id,
|
referrer_tg_id,
|
||||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
|
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {error}")
|
||||||
|
|
||||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
|
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {error}")
|
||||||
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
|
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
|
||||||
|
|||||||
+42
-14
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -21,6 +22,7 @@ from config import (
|
|||||||
SUPPORT_CHAT_URL,
|
SUPPORT_CHAT_URL,
|
||||||
TRIAL_TIME_DISABLE,
|
TRIAL_TIME_DISABLE,
|
||||||
)
|
)
|
||||||
|
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||||
from database import (
|
from database import (
|
||||||
add_user,
|
add_user,
|
||||||
get_coupon_by_code,
|
get_coupon_by_code,
|
||||||
@@ -41,12 +43,12 @@ from handlers.buttons import (
|
|||||||
)
|
)
|
||||||
from handlers.captcha import generate_captcha
|
from handlers.captcha import generate_captcha
|
||||||
from handlers.coupons import activate_coupon
|
from handlers.coupons import activate_coupon
|
||||||
from handlers.payments.gift import handle_gift_link
|
from handlers.instructions.instructions import send_instructions
|
||||||
from handlers.profile import process_callback_view_profile
|
|
||||||
from handlers.keys.key_mode.key_create import confirm_create_new_key
|
from handlers.keys.key_mode.key_create import confirm_create_new_key
|
||||||
from handlers.keys.key_view import process_callback_or_message_view_keys
|
from handlers.keys.key_view import process_callback_or_message_view_keys
|
||||||
|
from handlers.payments.gift import handle_gift_link
|
||||||
|
from handlers.profile import process_callback_view_profile
|
||||||
from handlers.refferal import invite_handler
|
from handlers.refferal import invite_handler
|
||||||
from handlers.instructions.instructions import send_instructions
|
|
||||||
from handlers.texts import (
|
from handlers.texts import (
|
||||||
NOT_SUBSCRIBED_YET_MSG,
|
NOT_SUBSCRIBED_YET_MSG,
|
||||||
SUBSCRIPTION_CHECK_ERROR_MSG,
|
SUBSCRIPTION_CHECK_ERROR_MSG,
|
||||||
@@ -71,17 +73,29 @@ processing_gifts = set()
|
|||||||
@router.message(Command("start"))
|
@router.message(Command("start"))
|
||||||
@router.callback_query(F.data == "start")
|
@router.callback_query(F.data == "start")
|
||||||
async def start_entry(
|
async def start_entry(
|
||||||
event: Message | CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = True
|
event: Message | CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
session: Any,
|
||||||
|
admin: bool,
|
||||||
|
captcha: bool = True,
|
||||||
):
|
):
|
||||||
message = event.message if isinstance(event, CallbackQuery) else event
|
message = event.message if isinstance(event, CallbackQuery) else event
|
||||||
if CAPTCHA_ENABLE and captcha:
|
|
||||||
|
captcha_enabled = bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE))
|
||||||
|
if captcha_enabled and captcha:
|
||||||
exists = await get_user_snapshot(session, message.chat.id)
|
exists = await get_user_snapshot(session, message.chat.id)
|
||||||
if exists is None:
|
if exists is None:
|
||||||
captcha_data = await generate_captcha(message, state)
|
captcha_data = await generate_captcha(message, state)
|
||||||
await edit_or_send_message(message, captcha_data["text"], reply_markup=captcha_data["markup"])
|
await edit_or_send_message(message, captcha_data["text"], reply_markup=captcha_data["markup"])
|
||||||
return
|
return
|
||||||
|
|
||||||
text = getattr(event, "data", None) or message.text
|
text = getattr(event, "data", None) or message.text
|
||||||
await process_start_logic(message, state, session, admin, text)
|
|
||||||
|
user_data = None
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
user_data = extract_user_data(event.from_user)
|
||||||
|
|
||||||
|
await process_start_logic(message, state, session, admin, text, user_data)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "check_subscription")
|
@router.callback_query(F.data == "check_subscription")
|
||||||
@@ -174,7 +188,9 @@ async def process_start_logic(
|
|||||||
if trial_key is not None:
|
if trial_key is not None:
|
||||||
trial, key_count = trial_key
|
trial, key_count = trial_key
|
||||||
|
|
||||||
if SHOW_START_MENU_ONCE:
|
show_start_menu_once = bool(MODES_CONFIG.get("SHOW_START_MENU_ONLY_ONCE", SHOW_START_MENU_ONCE))
|
||||||
|
|
||||||
|
if show_start_menu_once:
|
||||||
if key_count > 0 or trial == 1:
|
if key_count > 0 or trial == 1:
|
||||||
await process_callback_view_profile(message, state, admin, session)
|
await process_callback_view_profile(message, state, admin, session)
|
||||||
else:
|
else:
|
||||||
@@ -260,9 +276,16 @@ async def show_start_menu(
|
|||||||
trial_status = trial
|
trial_status = trial
|
||||||
key_cnt = key_count or 0
|
key_cnt = key_count or 0
|
||||||
|
|
||||||
show_trial = (trial_status in (-1, 0)) and (not TRIAL_TIME_DISABLE) and (key_cnt == 0)
|
trial_time_disable = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||||
|
|
||||||
|
show_trial = (trial_status in (-1, 0)) and (not trial_time_disable) and (key_cnt == 0)
|
||||||
show_profile = (key_cnt > 0) or (
|
show_profile = (key_cnt > 0) or (
|
||||||
((not SHOW_START_MENU_ONCE) or (trial_status not in (-1, 0)) or TRIAL_TIME_DISABLE) and (not show_trial)
|
(
|
||||||
|
(not bool(MODES_CONFIG.get("SHOW_START_MENU_ONLY_ONCE", SHOW_START_MENU_ONCE)))
|
||||||
|
or (trial_status not in (-1, 0))
|
||||||
|
or trial_time_disable
|
||||||
|
)
|
||||||
|
and (not show_trial)
|
||||||
)
|
)
|
||||||
|
|
||||||
if show_trial:
|
if show_trial:
|
||||||
@@ -270,7 +293,7 @@ async def show_start_menu(
|
|||||||
if show_profile:
|
if show_profile:
|
||||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
if CHANNEL_EXISTS:
|
if BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS):
|
||||||
kb.row(
|
kb.row(
|
||||||
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
|
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
|
||||||
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
|
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
|
||||||
@@ -297,14 +320,15 @@ async def handle_about_vpn(callback: CallbackQuery, session: AsyncSession):
|
|||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
snap = await get_user_snapshot(session, user_id)
|
snap = await get_user_snapshot(session, user_id)
|
||||||
trial = 0 if snap is None else snap[0]
|
trial = 0 if snap is None else snap[0]
|
||||||
back_target = "profile" if SHOW_START_MENU_ONCE and trial > 0 else "start"
|
show_start_menu_once = bool(MODES_CONFIG.get("SHOW_START_MENU_ONLY_ONCE", SHOW_START_MENU_ONCE))
|
||||||
|
back_target = "profile" if show_start_menu_once and trial > 0 else "start"
|
||||||
|
|
||||||
kb = InlineKeyboardBuilder()
|
kb = InlineKeyboardBuilder()
|
||||||
if DONATIONS_ENABLE:
|
if BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE):
|
||||||
kb.row(InlineKeyboardButton(text=DONAT_BUTTON, callback_data="donate"))
|
kb.row(InlineKeyboardButton(text=DONAT_BUTTON, callback_data="donate"))
|
||||||
|
|
||||||
kb.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
kb.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
if CHANNEL_EXISTS:
|
if BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS):
|
||||||
kb.row(InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL))
|
kb.row(InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL))
|
||||||
|
|
||||||
module_buttons = await run_hooks("about_menu", chat_id=user_id, trial=trial, session=session)
|
module_buttons = await run_hooks("about_menu", chat_id=user_id, trial=trial, session=session)
|
||||||
@@ -318,5 +342,9 @@ async def handle_about_vpn(callback: CallbackQuery, session: AsyncSession):
|
|||||||
text = text_hooks[0]
|
text = text_hooks[0]
|
||||||
|
|
||||||
await edit_or_send_message(
|
await edit_or_send_message(
|
||||||
callback.message, text, reply_markup=kb.as_markup(), media_path=os.path.join("img", "pic.jpg"), force_text=False
|
callback.message,
|
||||||
|
text,
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
media_path=os.path.join("img", "pic.jpg"),
|
||||||
|
force_text=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import html
|
import html
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from collections import OrderedDict
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from aiogram.types import Message, Update
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from config import DISABLE_DIRECT_START
|
from config import DISABLE_DIRECT_START
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from database import async_session_maker, check_user_exists
|
from database import async_session_maker, check_user_exists
|
||||||
from database.models import Coupon, Gift, TrackingSource, User
|
from database.models import Coupon, Gift, TrackingSource, User
|
||||||
from logger import logger
|
from logger import logger
|
||||||
@@ -32,7 +33,8 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
|
|||||||
event: Update,
|
event: Update,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
if not DISABLE_DIRECT_START:
|
direct_start_disabled = bool(MODES_CONFIG.get("DIRECT_START_DISABLED", DISABLE_DIRECT_START))
|
||||||
|
if not direct_start_disabled:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
message: Message | None = getattr(event, "message", None)
|
message: Message | None = getattr(event, "message", None)
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ from aiogram import BaseMiddleware
|
|||||||
from aiogram.types import CallbackQuery, Message, Update
|
from aiogram.types import CallbackQuery, Message, Update
|
||||||
|
|
||||||
from config import ADMIN_ID
|
from config import ADMIN_ID
|
||||||
|
from core.bootstrap import MANAGEGENT_CONFIG
|
||||||
from database import async_session_maker
|
from database import async_session_maker
|
||||||
from database.models import Admin
|
from database.models import Admin
|
||||||
|
|
||||||
|
|
||||||
maintenance_mode = False
|
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceModeMiddleware(BaseMiddleware):
|
class MaintenanceModeMiddleware(BaseMiddleware):
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -19,7 +17,8 @@ class MaintenanceModeMiddleware(BaseMiddleware):
|
|||||||
event: Update,
|
event: Update,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
if not maintenance_mode:
|
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||||
|
if not maintenance_enabled:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
user_id = None
|
user_id = None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
|
|
||||||
from bot import bot
|
from bot import bot
|
||||||
from config import CHANNEL_EXISTS, CHANNEL_ID, CHANNEL_REQUIRED, CHANNEL_URL
|
from config import CHANNEL_EXISTS, CHANNEL_ID, CHANNEL_REQUIRED, CHANNEL_URL
|
||||||
|
from core.bootstrap import MODES_CONFIG
|
||||||
from handlers.buttons import SUB_CHANELL, SUB_CHANELL_DONE
|
from handlers.buttons import SUB_CHANELL, SUB_CHANELL_DONE
|
||||||
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
|
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
|
||||||
from handlers.utils import edit_or_send_message
|
from handlers.utils import edit_or_send_message
|
||||||
@@ -23,7 +24,8 @@ class SubscriptionMiddleware(BaseMiddleware):
|
|||||||
event: Update,
|
event: Update,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
if not CHANNEL_EXISTS or not CHANNEL_REQUIRED:
|
channel_check_enabled = bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED))
|
||||||
|
if not CHANNEL_EXISTS or not channel_check_enabled:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
tg_id = None
|
tg_id = None
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.filters import ExceptionTypeFilter
|
||||||
|
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||||
|
from aiogram.utils.markdown import hbold
|
||||||
|
|
||||||
|
from config import ADMIN_ID
|
||||||
|
from database import async_session_maker
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_error_handlers(dp: Dispatcher) -> None:
|
||||||
|
@dp.errors(ExceptionTypeFilter(Exception))
|
||||||
|
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
||||||
|
if isinstance(event.exception, TelegramForbiddenError):
|
||||||
|
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(event.exception, TelegramBadRequest):
|
||||||
|
error_message = str(event.exception)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"query is too old and response timeout expired or query ID is invalid" in error_message
|
||||||
|
or "message can't be deleted for everyone" in error_message
|
||||||
|
or "message to delete not found" in error_message
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
tb = "".join(
|
||||||
|
traceback.format_exception(
|
||||||
|
type(event.exception),
|
||||||
|
event.exception,
|
||||||
|
event.exception.__traceback__,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
|
||||||
|
logger.error(f"Traceback:\n{tb}")
|
||||||
|
|
||||||
|
if ADMIN_ID:
|
||||||
|
if "query is too old and response timeout expired or query ID is invalid" in error_message:
|
||||||
|
caption = (
|
||||||
|
f"{hbold('TelegramBadRequest: устаревший callback-запрос')}\n\n"
|
||||||
|
"Что произошло:\n"
|
||||||
|
"• Пользователь нажал старую кнопку, или\n"
|
||||||
|
"• Telegram обработал callback уже после истечения таймаута.\n\n"
|
||||||
|
"Описание:\n"
|
||||||
|
"Такое может происходить из-за временной недоступности Telegram или "
|
||||||
|
"нестабильного подключения сервера к API (задержки, потери пакетов, очереди запросов).\n\n"
|
||||||
|
"Действия:\n"
|
||||||
|
"• Проверить стабильность интернет-соединения сервера.\n"
|
||||||
|
"• Оценить задержки/нагрузку на бота и частоту callback-запросов.\n"
|
||||||
|
"• При необходимости оптимизировать обработку или уменьшить время между нажатием кнопки и ответом."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
caption = f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}..."
|
||||||
|
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id=admin_id,
|
||||||
|
document=BufferedInputFile(
|
||||||
|
tb.encode(),
|
||||||
|
filename=f"error_{event.update.update_id}.txt",
|
||||||
|
),
|
||||||
|
caption=caption[:1024],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from handlers.start import start_entry
|
||||||
|
|
||||||
|
if event.update.message:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.message.chat.id,
|
||||||
|
user_id=event.update.message.from_user.id,
|
||||||
|
)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await start_entry(
|
||||||
|
event=event.update.message,
|
||||||
|
state=fsm_context,
|
||||||
|
session=session,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
elif event.update.callback_query:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.callback_query.message.chat.id,
|
||||||
|
user_id=event.update.callback_query.from_user.id,
|
||||||
|
)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await start_entry(
|
||||||
|
event=event.update.callback_query,
|
||||||
|
state=fsm_context,
|
||||||
|
session=session,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||||
|
|
||||||
|
if not ADMIN_ID:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id=admin_id,
|
||||||
|
document=BufferedInputFile(
|
||||||
|
traceback.format_exc().encode(),
|
||||||
|
filename=f"error_{event.update.update_id}.txt",
|
||||||
|
),
|
||||||
|
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||||
|
)
|
||||||
|
|
||||||
|
from handlers.start import start_entry
|
||||||
|
|
||||||
|
if event.update.message:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.message.chat.id,
|
||||||
|
user_id=event.update.message.from_user.id,
|
||||||
|
)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await start_entry(
|
||||||
|
event=event.update.message,
|
||||||
|
state=fsm_context,
|
||||||
|
session=session,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
elif event.update.callback_query:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.callback_query.message.chat.id,
|
||||||
|
user_id=event.update.callback_query.from_user.id,
|
||||||
|
)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await start_entry(
|
||||||
|
event=event.update.callback_query,
|
||||||
|
state=fsm_context,
|
||||||
|
session=session,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TelegramBadRequest as exception:
|
||||||
|
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
||||||
|
except Exception as exception:
|
||||||
|
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
||||||
|
|
||||||
|
return True
|
||||||
+1
-1
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
return f"v.5-Release {get_git_commit_number()}"
|
return f"v.5.1-preAlpha {get_git_commit_number()}"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from handlers.payments.heleket.webhook import heleket_webhook
|
|||||||
from handlers.payments.kassai.webhook import kassai_webhook
|
from handlers.payments.kassai.webhook import kassai_webhook
|
||||||
from utils.modules_loader import load_module_webhooks
|
from utils.modules_loader import load_module_webhooks
|
||||||
|
|
||||||
|
|
||||||
KASSAI_WEBHOOK_PATH = "/kassai/webhook"
|
KASSAI_WEBHOOK_PATH = "/kassai/webhook"
|
||||||
HELEKET_WEBHOOK_PATH = "/heleket/webhook"
|
HELEKET_WEBHOOK_PATH = "/heleket/webhook"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user