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.client.default import DefaultBotProperties
|
||||
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.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from database import async_session_maker
|
||||
from config import API_TOKEN
|
||||
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
|
||||
|
||||
|
||||
@@ -27,125 +20,4 @@ load_modules_from_folder()
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
@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
|
||||
setup_error_handlers(dp)
|
||||
|
||||
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)
|
||||
@@ -262,3 +262,13 @@ class Admin(Base):
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
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
|
||||
@@ -14,6 +14,7 @@ from .panel import router as panel_router
|
||||
from .restart import router as restart_router
|
||||
from .sender import router as sender_router
|
||||
from .servers import router as servers_router
|
||||
from .settings import router as settings_router
|
||||
from .stats import router as stats_router
|
||||
from .tariffs import router as tariffs_router
|
||||
from .users import router as users_router
|
||||
@@ -37,4 +38,5 @@ router.include_routers(
|
||||
ads_router,
|
||||
tariffs_router,
|
||||
gifts_router,
|
||||
settings_router,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from database.models import BlockedUser, Key, ManualBan
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import (
|
||||
build_bans_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())
|
||||
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text=get_shadow_bans_menu_text(),
|
||||
reply_markup=build_shadow_bans_kb()
|
||||
)
|
||||
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
|
||||
async def handle_manual_bans_menu(callback_query: CallbackQuery):
|
||||
text_ = (
|
||||
"🔒 <b>Ручные баны</b>\n\n"
|
||||
"Пользователи, которых вы забанили через админку.\n"
|
||||
"⬇ Выберите действие:"
|
||||
)
|
||||
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
|
||||
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):
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
stmt = (
|
||||
select(BlockedUser.tg_id)
|
||||
.outerjoin(Key, BlockedUser.tg_id == Key.tg_id)
|
||||
.where(Key.tg_id.is_(None))
|
||||
)
|
||||
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
|
||||
result = await session.execute(stmt)
|
||||
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()
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until)
|
||||
.where(ManualBan.reason == "shadow")
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
|
||||
ManualBan.reason == "shadow"
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
@@ -182,8 +172,9 @@ async def handle_manual_bans_export(callback_query: CallbackQuery, session: Asyn
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by)
|
||||
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
|
||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
@@ -275,9 +266,9 @@ async def handle_clear_manual_bans(callback_query: CallbackQuery, session: Async
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
count_result = await session.execute(
|
||||
select(func.count()).select_from(ManualBan).where(
|
||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||
)
|
||||
select(func.count())
|
||||
.select_from(ManualBan)
|
||||
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
@@ -288,11 +279,7 @@ async def handle_clear_manual_bans(callback_query: CallbackQuery, session: Async
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
delete(ManualBan).where(
|
||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||
)
|
||||
)
|
||||
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
@@ -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())
|
||||
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
text=get_shadow_bans_menu_text(),
|
||||
reply_markup=build_shadow_bans_kb()
|
||||
)
|
||||
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@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:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔁 Обновить", callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack())],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())],
|
||||
])
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
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.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 handlers.utils import edit_or_send_message, format_days, format_months
|
||||
from logger import logger
|
||||
@@ -181,44 +181,18 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not gifts:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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))
|
||||
|
||||
builder.inline_keyboard.extend(keyboard.inline_keyboard)
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
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())
|
||||
await callback.message.edit_text(
|
||||
f"🎁 <b>Список подарков</b>\nСтраница {page}:",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@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 handlers.buttons import BACK
|
||||
from handlers.utils import format_days, format_months
|
||||
from handlers.utils import format_days
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
@@ -37,11 +37,8 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
|
||||
row = []
|
||||
|
||||
for i, gift in enumerate(gifts):
|
||||
if gift.selected_months > 0:
|
||||
duration_text = format_months(gift.selected_months)
|
||||
else:
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
|
||||
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.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from middlewares import maintenance
|
||||
|
||||
from core.bootstrap import MANAGEGENT_CONFIG
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
@@ -39,7 +38,9 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
text="📤 Загрузить файл",
|
||||
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(
|
||||
text=maintenance_text,
|
||||
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||
@@ -72,7 +73,8 @@ def build_database_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||
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()
|
||||
|
||||
|
||||
@@ -105,7 +107,6 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
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())
|
||||
|
||||
if role == "superadmin":
|
||||
@@ -143,14 +144,6 @@ def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
|
||||
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:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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
|
||||
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
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)
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
builder.button(
|
||||
text="⚙️ Настройки",
|
||||
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text=MAIN_MENU,
|
||||
callback_data="profile",
|
||||
)
|
||||
|
||||
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:
|
||||
builder.adjust(2, 2, 1, 1)
|
||||
builder.adjust(2, 2, 1, 1, 1)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .servers_handler import router
|
||||
from . import (
|
||||
server_manage, # noqa: F401
|
||||
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.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.types import CallbackQuery
|
||||
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 database import get_servers
|
||||
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 handlers.buttons import BACK
|
||||
|
||||
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_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()
|
||||
from .keyboard import AdminServerCallback, build_manage_server_kb
|
||||
from .server_states import ServerLimitState, router
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
@@ -102,11 +76,19 @@ async def process_callback_delete_server(
|
||||
state: FSMContext,
|
||||
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
|
||||
|
||||
servers_dict = await get_servers(session, include_enabled=True)
|
||||
servers_dict = await get_servers_inner(session, include_enabled=True)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers_dict.items():
|
||||
if any(s["server_name"] == server_name for s in server_list):
|
||||
@@ -120,16 +102,23 @@ async def process_callback_delete_server(
|
||||
)
|
||||
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)
|
||||
keys_count = result.scalar_one()
|
||||
|
||||
if keys_count > 0:
|
||||
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)
|
||||
all_servers = result.all()
|
||||
|
||||
@@ -139,20 +128,22 @@ async def process_callback_delete_server(
|
||||
callback_data = f"transfer_to_server|{s_name}|{server_name}"
|
||||
if len(callback_data.encode("utf-8")) > 64:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
||||
text=(
|
||||
f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{s_name} ({key_count})",
|
||||
callback_data=callback_data,
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=BACK,
|
||||
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()
|
||||
|
||||
if cluster_keys_count > 0:
|
||||
from ..clusters.base import AdminClusterStates
|
||||
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
|
||||
subq_cluster = (
|
||||
@@ -201,41 +194,53 @@ async def process_callback_delete_server(
|
||||
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
|
||||
if len(callback_data.encode("utf-8")) > 64:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
||||
text=(
|
||||
f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{cl_name} ({key_count})",
|
||||
callback_data=callback_data,
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
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(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||
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.commit()
|
||||
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"),
|
||||
)
|
||||
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.commit()
|
||||
await callback_query.message.edit_text(
|
||||
@@ -341,140 +346,3 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
|
||||
|
||||
except ValueError:
|
||||
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,16 +144,13 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
||||
for name, count in sorted_buckets:
|
||||
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
|
||||
for tariffs_list in subgroups_dict.values():
|
||||
group_total += sum(count for _, count in tariffs_list)
|
||||
|
||||
tariff_stats_text += f"Тариф <b>{group}</b> (<b>{group_total}</b>)\n"
|
||||
sorted_subgroups = sorted(
|
||||
subgroups_dict.items(),
|
||||
key=lambda x: (x[0] is None, x[0] or "")
|
||||
)
|
||||
sorted_subgroups = sorted(subgroups_dict.items(), key=lambda x: (x[0] is None, x[0] or ""))
|
||||
for subgroup_idx, (subgroup, tariffs) in enumerate(sorted_subgroups):
|
||||
sorted_tariffs = sorted(tariffs, key=lambda x: tariff_durations.get(x[0], 0))
|
||||
subgroup_total = sum(count for _, count in sorted_tariffs)
|
||||
@@ -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>{registrations_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>"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.types import (
|
||||
CallbackQuery,
|
||||
FSInputFile,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
WebAppInfo,
|
||||
@@ -15,7 +14,8 @@ from aiogram.types import (
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
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 (
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
@@ -25,10 +25,8 @@ from database import (
|
||||
)
|
||||
from handlers.buttons import (
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
MAIN_MENU,
|
||||
MY_SUB,
|
||||
PC_BUTTON,
|
||||
ROUTER_BUTTON,
|
||||
SUPPORT,
|
||||
TV_BUTTON,
|
||||
@@ -179,12 +177,12 @@ async def key_cluster_mode(
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
else:
|
||||
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||
use_webapp = REMNAWAVE_WEBAPP
|
||||
if REMNAWAVE_WEBAPP and final_link:
|
||||
use_webapp = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||
if use_webapp and final_link:
|
||||
try:
|
||||
webapp_override_results = await run_hooks(
|
||||
"remnawave_webapp_override",
|
||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
||||
remnawave_webapp=use_webapp,
|
||||
final_link=final_link,
|
||||
session=session,
|
||||
)
|
||||
@@ -197,19 +195,19 @@ async def key_cluster_mode(
|
||||
except Exception as 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=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||
else:
|
||||
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:
|
||||
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=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
@@ -19,13 +19,13 @@ from bot import bot
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
REMNAWAVE_WEBAPP,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import (
|
||||
add_user,
|
||||
check_server_name_by_cluster,
|
||||
@@ -41,10 +41,8 @@ from database.models import Key, Server, Tariff
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
MAIN_MENU,
|
||||
MY_SUB,
|
||||
PC_BUTTON,
|
||||
ROUTER_BUTTON,
|
||||
SUPPORT,
|
||||
TV_BUTTON,
|
||||
@@ -488,7 +486,7 @@ async def finalize_key_creation(
|
||||
)
|
||||
|
||||
if not remnawave_link:
|
||||
if HAPP_CRYPTOLINK:
|
||||
if bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)):
|
||||
happ = sub.get("happ") or {}
|
||||
remnawave_link = happ.get("cryptoLink") or happ.get("link")
|
||||
if not remnawave_link:
|
||||
@@ -593,12 +591,12 @@ async def finalize_key_creation(
|
||||
else None
|
||||
)
|
||||
|
||||
use_webapp = REMNAWAVE_WEBAPP
|
||||
if REMNAWAVE_WEBAPP and webapp_url:
|
||||
use_webapp = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||
if use_webapp and webapp_url:
|
||||
try:
|
||||
webapp_override_results = await run_hooks(
|
||||
"remnawave_webapp_override",
|
||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
||||
remnawave_webapp=use_webapp,
|
||||
final_link=final_link,
|
||||
session=session,
|
||||
)
|
||||
@@ -617,15 +615,9 @@ async def finalize_key_creation(
|
||||
else:
|
||||
if use_webapp and 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:
|
||||
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:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
@@ -89,7 +90,8 @@ async def handle_key_creation(
|
||||
try:
|
||||
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)
|
||||
if trial_status in [0, -1]:
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
@@ -109,7 +111,8 @@ async def handle_key_creation(
|
||||
|
||||
trial_tariff = trial_tariffs[0]
|
||||
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
|
||||
expiry_time = current_time + timedelta(days=total_days)
|
||||
|
||||
@@ -156,8 +159,6 @@ async def handle_key_creation(
|
||||
if tariffs:
|
||||
group_code = tariffs[0].get("group_code")
|
||||
if group_code:
|
||||
from database.notifications import check_hot_lead_discount
|
||||
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
|
||||
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"):
|
||||
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
||||
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(
|
||||
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)
|
||||
|
||||
@@ -486,7 +489,9 @@ async def create_key(
|
||||
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(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DISCOUNT_ACTIVE_HOURS
|
||||
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||
from database import get_keys, get_tariffs
|
||||
from database.models import 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("❌ Скидка недоступна.")
|
||||
return
|
||||
|
||||
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||
|
||||
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("⏳ Срок действия скидки истёк.")
|
||||
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=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(
|
||||
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
|
||||
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\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(),
|
||||
)
|
||||
else:
|
||||
@@ -82,7 +85,6 @@ async def handle_discount_tariff_selection(callback: CallbackQuery, session, sta
|
||||
data=f"select_tariff_plan|{tariff_id}",
|
||||
)
|
||||
await select_tariff_plan(fake_callback, session=session, state=state)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
|
||||
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
|
||||
@@ -104,8 +106,10 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||
return
|
||||
|
||||
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||
|
||||
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("⏳ Срок действия финальной скидки истёк.")
|
||||
return
|
||||
|
||||
@@ -121,7 +125,7 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
||||
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\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(),
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -14,13 +14,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
|
||||
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||
from database import (
|
||||
check_tariff_exists,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
get_tariffs,
|
||||
update_balance,
|
||||
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:
|
||||
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 = ""
|
||||
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
|
||||
expires_at = discount_info["expires_at"]
|
||||
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)
|
||||
|
||||
@@ -362,10 +367,12 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
discount_message = ""
|
||||
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
|
||||
expires_at = discount_info["expires_at"]
|
||||
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)
|
||||
|
||||
@@ -383,8 +390,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id = callback_query.data.split("|")[1]
|
||||
tariff_id = int(tariff_id)
|
||||
tariff_id = int(callback_query.data.split("|")[1])
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("renew_client_id")
|
||||
|
||||
+39
-45
@@ -17,7 +17,6 @@ from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
HWID_RESET_BUTTON,
|
||||
@@ -28,6 +27,7 @@ from config import (
|
||||
TOGGLE_CLIENT,
|
||||
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.models import Key
|
||||
from handlers.buttons import (
|
||||
@@ -35,12 +35,10 @@ from handlers.buttons import (
|
||||
BACK,
|
||||
CHANGE_LOCATION,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
DELETE,
|
||||
FREEZE,
|
||||
HWID_BUTTON,
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW_KEY,
|
||||
ROUTER_BUTTON,
|
||||
@@ -71,6 +69,7 @@ from panels.remnawave import RemnawaveAPI
|
||||
|
||||
|
||||
router = Router()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
class RenameKeyState(StatesGroup):
|
||||
@@ -105,17 +104,13 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
|
||||
reply_markup=inline_keyboard,
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
except Exception as error:
|
||||
error_message = f"Ошибка при получении ключей: {error}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
|
||||
async def build_keys_response(records, session):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
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)
|
||||
if tariff and tariff.get("vless"):
|
||||
is_vless = True
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
except Exception as error:
|
||||
await message.answer("❌ Не удалось переименовать подписку.")
|
||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
||||
logger.error(f"Ошибка при обновлении alias: {error}")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
@@ -293,8 +287,8 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
if user_data:
|
||||
used_bytes = user_data.get("usedTrafficBytes", 0)
|
||||
remna_used_gb = round(used_bytes / 1073741824, 1)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {error}")
|
||||
|
||||
tariff_name = ""
|
||||
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
|
||||
|
||||
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(
|
||||
final_link,
|
||||
formatted_expiry_date,
|
||||
days_left_message,
|
||||
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,
|
||||
tariff_name=tariff_duration,
|
||||
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,
|
||||
)
|
||||
|
||||
use_webapp = REMNAWAVE_WEBAPP
|
||||
if is_full_remnawave and final_link and REMNAWAVE_WEBAPP and not HAPP_CRYPTOLINK:
|
||||
use_webapp = remnawave_webapp_enabled
|
||||
if is_full_remnawave and final_link and remnawave_webapp_enabled and not happ_cryptolink_enabled:
|
||||
try:
|
||||
webapp_override_results = await run_hooks(
|
||||
"remnawave_webapp_override",
|
||||
remnawave_webapp=REMNAWAVE_WEBAPP,
|
||||
remnawave_webapp=remnawave_webapp_enabled,
|
||||
final_link=final_link,
|
||||
session=session,
|
||||
)
|
||||
@@ -340,47 +338,41 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
use_webapp = hook_result
|
||||
elif isinstance(hook_result, dict) and "override" in hook_result:
|
||||
use_webapp = hook_result["override"]
|
||||
except Exception as e:
|
||||
logger.warning(f"[REMNAWAVE_WEBAPP_OVERRIDE] Ошибка при применении хуков: {e}")
|
||||
except Exception as error:
|
||||
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:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
else:
|
||||
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}"))
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{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}"),
|
||||
)
|
||||
if vless_enabled:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
else:
|
||||
if vless_enabled:
|
||||
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=CONNECT_DEVICE, callback_data=f"connect_device|{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}"))
|
||||
|
||||
if QRCODE:
|
||||
if qrcode_enabled:
|
||||
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}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
if country_selection_enabled:
|
||||
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=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
|
||||
)
|
||||
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
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:
|
||||
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
|
||||
|
||||
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 database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from logger import logger
|
||||
from panels._3xui import get_vless_link_for_client, get_xui_instance
|
||||
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]:
|
||||
happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||
|
||||
si = servers[0]
|
||||
remna = RemnawaveAPI(si["api_url"])
|
||||
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:
|
||||
pass
|
||||
|
||||
if HAPP_CRYPTOLINK and happ_link:
|
||||
if happ_cryptolink_enabled and happ_link:
|
||||
return best, happ_link
|
||||
|
||||
sub_url = data.get("subscriptionUrl")
|
||||
@@ -102,6 +105,8 @@ async def make_aggregated_link(
|
||||
remna_link_override: str | None = None,
|
||||
plan=None,
|
||||
) -> str | None:
|
||||
legacy_links_enabled = bool(MODES_CONFIG.get("LEGACY_LINKS_ENABLED", LEGACY_LINKS))
|
||||
|
||||
servers = (
|
||||
await filter_cluster_by_subgroup(session, cluster_all, subgroup_code, cluster_id)
|
||||
if subgroup_code
|
||||
@@ -125,7 +130,7 @@ async def make_aggregated_link(
|
||||
base = PUBLIC_LINK.rstrip("/")
|
||||
|
||||
if vless_needed:
|
||||
if LEGACY_LINKS:
|
||||
if legacy_links_enabled:
|
||||
if xui:
|
||||
xui_link = await _try_build_3xui_vless(xui, email)
|
||||
if xui_link:
|
||||
@@ -158,14 +163,12 @@ async def make_aggregated_link(
|
||||
return f"{base}/{email}/{tg_id}"
|
||||
|
||||
if remna and not xui:
|
||||
if LEGACY_LINKS:
|
||||
if legacy_links_enabled:
|
||||
logger.info("[agg_link] LEGACY non-vless -> base link")
|
||||
return f"{base}/{email}/{tg_id}"
|
||||
best_vless, sub_url = await _try_build_remna_vless(remna, email)
|
||||
if remna_link_override and (
|
||||
remna_link_override.lower().startswith("vless://") or
|
||||
remna_link_override.startswith("http") or
|
||||
remna_link_override.startswith("happ://")
|
||||
remna_link_override.lower().startswith("vless://") or remna_link_override.startswith(("http", "happ://"))
|
||||
):
|
||||
logger.info("[agg_link] choose override Remnawave (non-vless)")
|
||||
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 database import get_servers, get_tariff_by_id, store_key
|
||||
from database.models import User
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import (
|
||||
@@ -152,7 +153,10 @@ async def create_key_on_cluster(
|
||||
except Exception as 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:
|
||||
hook_results = await run_hooks(
|
||||
"happ_cryptolink_override",
|
||||
@@ -161,7 +165,7 @@ async def create_key_on_cluster(
|
||||
session=session,
|
||||
email=email,
|
||||
tg_id=tg_id,
|
||||
happ_cryptolink=HAPP_CRYPTOLINK,
|
||||
happ_cryptolink=base_use_crypto_link,
|
||||
)
|
||||
if hook_results:
|
||||
for hook_result in hook_results:
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import filter_cluster_by_subgroup, update_key_client_id
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from logger import (
|
||||
CLOGGER as logger,
|
||||
PANEL_REMNA,
|
||||
@@ -42,6 +43,7 @@ async def ensure_on_remnawave(
|
||||
|
||||
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
|
||||
traffic_bytes = bytes_from_gb(total_gb)
|
||||
use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||
|
||||
async def do_update():
|
||||
try:
|
||||
@@ -82,7 +84,7 @@ async def ensure_on_remnawave(
|
||||
new_uuid = created.get("uuid") if isinstance(created, dict) else None
|
||||
remna_link = None
|
||||
if isinstance(created, dict):
|
||||
if HAPP_CRYPTOLINK:
|
||||
if use_crypto_link:
|
||||
remna_link = (
|
||||
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 [], {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching URL {url}: {e}")
|
||||
logger.debug(f"Error fetching URL {url}: {e}")
|
||||
return [], {}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from config import (
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
@@ -67,9 +68,11 @@ notification_lock = asyncio.Lock()
|
||||
|
||||
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
while True:
|
||||
notification_interval = int(NOTIFICATIONS_CONFIG.get("BASE_NOTIFICATION_MINUTE", NOTIFICATION_TIME))
|
||||
|
||||
if notification_lock.locked():
|
||||
logger.warning("Уведомления уже выполняются. Пропуск...")
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
await asyncio.sleep(notification_interval)
|
||||
continue
|
||||
|
||||
async with notification_lock:
|
||||
@@ -82,60 +85,89 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
try:
|
||||
keys = await get_all_keys(session=session)
|
||||
keys = [k for k in keys if not k.is_frozen]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при получении ключей: {error}")
|
||||
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:
|
||||
await notify_inactive_trial_users(bot, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
|
||||
except Exception as error:
|
||||
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:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_24h_keys: {e}")
|
||||
await notify_24h_keys(
|
||||
bot,
|
||||
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:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_10h_keys: {e}")
|
||||
await notify_10h_keys(
|
||||
bot,
|
||||
session,
|
||||
current_time,
|
||||
threshold_10h,
|
||||
keys,
|
||||
notify_10_hours,
|
||||
notify_renew_enabled,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_10h_keys: {error}")
|
||||
|
||||
try:
|
||||
await handle_expired_keys(bot, session, current_time, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в handle_expired_keys: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в handle_expired_keys: {error}")
|
||||
|
||||
if NOTIFY_INACTIVE_TRAFFIC:
|
||||
if inactive_traffic_enabled:
|
||||
try:
|
||||
await notify_users_no_traffic(bot, session, current_time, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_users_no_traffic: {error}")
|
||||
|
||||
try:
|
||||
await run_hooks("periodic_notifications", bot=bot, session=session, keys=keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в хуках periodic_notifications: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в хуках periodic_notifications: {error}")
|
||||
|
||||
if NOTIFY_HOT_LEADS:
|
||||
if notify_hot_leads_enabled:
|
||||
try:
|
||||
await notify_hot_leads(bot, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_hot_leads: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_hot_leads: {error}")
|
||||
|
||||
logger.info("Уведомления завершены")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в periodic_notifications: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в periodic_notifications: {error}")
|
||||
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
await asyncio.sleep(notification_interval)
|
||||
|
||||
|
||||
async def notify_24h_keys(
|
||||
@@ -144,16 +176,18 @@ async def notify_24h_keys(
|
||||
current_time: int,
|
||||
threshold_time_24h: int,
|
||||
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]
|
||||
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]
|
||||
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 = []
|
||||
|
||||
for key in expiring_keys:
|
||||
@@ -164,7 +198,7 @@ async def notify_24h_keys(
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -178,7 +212,7 @@ async def notify_24h_keys(
|
||||
tariff_details=expiry_data["tariff_details"],
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
if notify_renew_enabled:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot,
|
||||
@@ -189,8 +223,8 @@ async def notify_24h_keys(
|
||||
"notify_24h.jpg",
|
||||
notification_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {error}")
|
||||
continue
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
@@ -217,9 +251,9 @@ async def notify_24h_keys(
|
||||
logger.warning(
|
||||
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)
|
||||
|
||||
|
||||
@@ -229,16 +263,18 @@ async def notify_10h_keys(
|
||||
current_time: int,
|
||||
threshold_time_10h: int,
|
||||
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]
|
||||
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]
|
||||
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 = []
|
||||
|
||||
for key in expiring_keys:
|
||||
@@ -249,7 +285,7 @@ async def notify_10h_keys(
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -263,7 +299,7 @@ async def notify_10h_keys(
|
||||
tariff_details=expiry_data["tariff_details"],
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
if notify_renew_enabled:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot,
|
||||
@@ -274,8 +310,8 @@ async def notify_10h_keys(
|
||||
"notify_10h.jpg",
|
||||
notification_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {error}")
|
||||
continue
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
@@ -302,9 +338,9 @@ async def notify_10h_keys(
|
||||
logger.warning(
|
||||
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)
|
||||
|
||||
|
||||
@@ -323,6 +359,11 @@ async def handle_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)
|
||||
|
||||
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 = []
|
||||
|
||||
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)
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
if notify_renew_expired_enabled:
|
||||
try:
|
||||
balance = await get_balance(session, tg_id)
|
||||
tariffs = await get_tariffs_for_cluster(session, server_id)
|
||||
@@ -356,19 +397,19 @@ async def handle_expired_keys(
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {error}")
|
||||
continue
|
||||
|
||||
if NOTIFY_DELETE_KEY:
|
||||
delete_immediately = NOTIFY_DELETE_DELAY == 0
|
||||
if notify_delete_key_enabled:
|
||||
delete_immediately = delete_key_delay_minutes == 0
|
||||
delete_after_delay = False
|
||||
|
||||
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(
|
||||
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:
|
||||
@@ -386,16 +427,16 @@ async def handle_expired_keys(
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {error}")
|
||||
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)
|
||||
|
||||
if NOTIFY_DELETE_DELAY > 0:
|
||||
hours = NOTIFY_DELETE_DELAY // 60
|
||||
minutes = NOTIFY_DELETE_DELAY % 60
|
||||
if delete_key_delay_minutes > 0:
|
||||
hours = delete_key_delay_minutes // 60
|
||||
minutes = delete_key_delay_minutes % 60
|
||||
if hours > 0 and minutes > 0:
|
||||
time_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
|
||||
elif hours > 0:
|
||||
@@ -480,8 +521,8 @@ async def process_auto_renew_or_notify(
|
||||
for hook_result in hook_results:
|
||||
additional_groups = hook_result.get("additional_groups", [])
|
||||
forbidden_groups.extend(additional_groups)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
|
||||
except Exception as error:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||
|
||||
if current_tariff["group_code"] in forbidden_groups:
|
||||
selected_tariff = None
|
||||
@@ -506,8 +547,8 @@ async def process_auto_renew_or_notify(
|
||||
for hook_result in hook_results:
|
||||
additional_groups = hook_result.get("additional_groups", [])
|
||||
forbidden_groups.extend(additional_groups)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
|
||||
except Exception as error:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||
|
||||
if current_tariff["group_code"] in forbidden_groups:
|
||||
use_change_tariff_kb = True
|
||||
@@ -547,7 +588,6 @@ async def process_auto_renew_or_notify(
|
||||
client_id = key.client_id
|
||||
current_expiry = key.expiry_time
|
||||
duration_days = selected_tariff["duration_days"]
|
||||
selected_tariff["name"]
|
||||
renewal_cost = selected_tariff["price_rub"]
|
||||
traffic_limit = selected_tariff["traffic_limit"]
|
||||
device_limit = selected_tariff["device_limit"]
|
||||
@@ -567,7 +607,8 @@ async def process_auto_renew_or_notify(
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -606,5 +647,5 @@ async def process_auto_renew_or_notify(
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {error}")
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.models import Notification
|
||||
from handlers.buttons import MAIN_MENU
|
||||
@@ -21,6 +22,9 @@ from logger import logger
|
||||
async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||
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:
|
||||
leads = await get_hot_leads(session)
|
||||
notified = 0
|
||||
@@ -42,7 +46,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_1",
|
||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
||||
hours=hot_lead_interval_hours,
|
||||
)
|
||||
if not can_send:
|
||||
continue
|
||||
@@ -68,13 +72,19 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_2",
|
||||
hours=DISCOUNT_ACTIVE_HOURS,
|
||||
hours=discount_active_hours,
|
||||
)
|
||||
if expired:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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:
|
||||
await add_notification(session, tg_id, "hot_lead_step_2_expired")
|
||||
logger.info(f"📭 Скидка упущена — отправлено уведомление: {tg_id}")
|
||||
@@ -85,7 +95,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_2",
|
||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
||||
hours=hot_lead_interval_hours,
|
||||
)
|
||||
if not can_send:
|
||||
continue
|
||||
|
||||
@@ -8,13 +8,13 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
NOTIFY_INACTIVE,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
REMNAWAVE_WEBAPP,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notifications_bulk,
|
||||
@@ -22,7 +22,7 @@ from database import (
|
||||
update_key_notified,
|
||||
)
|
||||
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.notifications.notify_utils import send_messages_with_limit
|
||||
from handlers.texts import (
|
||||
@@ -42,7 +42,15 @@ moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||
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)} неактивных пользователей для уведомления.")
|
||||
messages = []
|
||||
|
||||
@@ -67,11 +75,11 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + trial_days
|
||||
if trial_extended and extra_days > 0:
|
||||
total_days = extra_days + trial_days
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
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),
|
||||
)
|
||||
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)
|
||||
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:
|
||||
tg_id = key.tg_id
|
||||
email = key.email
|
||||
@@ -122,7 +137,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
if expiry_time:
|
||||
@@ -132,12 +147,12 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
|
||||
try:
|
||||
traffic_data = await get_user_traffic(session, tg_id, email)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения трафика для {email}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения трафика для {email}: {error}")
|
||||
continue
|
||||
|
||||
if traffic_data.get("status") != "success":
|
||||
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||
logger.warning(f"Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||
continue
|
||||
|
||||
total_traffic = sum(
|
||||
@@ -145,7 +160,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
)
|
||||
|
||||
if total_traffic == 0:
|
||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
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)
|
||||
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)))
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{email}"))
|
||||
builder.row(
|
||||
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}"))
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при определении типа панели для {email}: {error}")
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
||||
|
||||
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:
|
||||
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:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
except Exception as e:
|
||||
logger.warning(f"[ZERO_TRAFFIC_NOTIFICATION] Ошибка при применении хуков: {e}")
|
||||
except Exception as error:
|
||||
logger.warning(f"[ZERO_TRAFFIC_NOTIFICATION] Ошибка при применении хуков: {error}")
|
||||
|
||||
keyboard = builder.as_markup()
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
@@ -191,8 +203,8 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
|
||||
try:
|
||||
await update_key_notified(session, tg_id, client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {error}")
|
||||
|
||||
if messages:
|
||||
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)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
logger.info("Обработка пользователей с нулевым трафиком завершена.")
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
import sqlalchemy as sa
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Optional, Tuple
|
||||
|
||||
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"
|
||||
@@ -25,8 +29,8 @@ def _round2(x: Decimal) -> Decimal:
|
||||
async def to_rub(amount: float | Decimal, base: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
|
||||
"""
|
||||
Переводит сумму ИЗ валюты base В РУБЛИ.
|
||||
Использует get_rub_rate(base): base_per_rub, т.е. СКОЛЬКО единиц base в 1 рубле.
|
||||
RUB = amount / (base_per_rub).
|
||||
Использует get_rub_rate(base): base_per_rub, т.е. сколько единиц base в 1 рубле.
|
||||
RUB = amount / base_per_rub.
|
||||
"""
|
||||
rate = await get_rub_rate(base, session=session)
|
||||
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":
|
||||
return Decimal("1")
|
||||
|
||||
if code == "USD" and RUB_TO_USD not in (False, None, 0):
|
||||
rate = _q(Decimal("1") / Decimal(str(RUB_TO_USD)))
|
||||
rub_to_usd_cfg = MONEY_CONFIG.get("RUB_TO_USD", DEFAULT_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)
|
||||
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)))
|
||||
rate = _q(Decimal("1") / rub_per_unit)
|
||||
|
||||
if code != "RUB" and FX_MARKUP:
|
||||
pct = Decimal(str(FX_MARKUP)) / Decimal("100")
|
||||
fx_markup_cfg = MONEY_CONFIG.get("FX_MARKUP", DEFAULT_FX_MARKUP)
|
||||
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))
|
||||
|
||||
cache[code] = (now, rate)
|
||||
@@ -80,7 +98,7 @@ async def convert_from_rub(
|
||||
amount_rub: Decimal | float,
|
||||
to_ccy: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession | None = None
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> Decimal:
|
||||
"""
|
||||
Конвертирует сумму из RUB в валюту to_ccy, используя get_rub_rate(to_ccy).
|
||||
@@ -97,14 +115,24 @@ async def convert_from_rub(
|
||||
def pick_currency(
|
||||
language_code: str | None,
|
||||
user_currency: str | None = None,
|
||||
force_currency: str | None = None
|
||||
force_currency: str | None = None,
|
||||
) -> str:
|
||||
if not MULTICURRENCY_ENABLE:
|
||||
return "RUB"
|
||||
if force_currency in {"USD", "RUB"}:
|
||||
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"}:
|
||||
return user_currency
|
||||
|
||||
code = (language_code or "").split("-")[0].lower()
|
||||
return "RUB" if code == "ru" else "USD"
|
||||
|
||||
@@ -128,18 +156,15 @@ async def display_price(
|
||||
force_currency: str | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> tuple[str, str, Decimal]:
|
||||
if force_currency in {"USD", "RUB"}:
|
||||
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
|
||||
cur = pick_currency(language_code, user_currency=user_currency, force_currency=force_currency)
|
||||
|
||||
val = _round2(Decimal(str(amount_rub)))
|
||||
txt = fmt_money(val, "RUB", language_code)
|
||||
return txt, "RUB", val
|
||||
if cur == "RUB":
|
||||
val = _round2(Decimal(str(amount_rub)))
|
||||
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(
|
||||
@@ -151,9 +176,9 @@ async def money_for_user(
|
||||
) -> Tuple[str, str, Decimal]:
|
||||
"""
|
||||
Возвращает: (text, currency, value)
|
||||
- text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
|
||||
- currency: "USD" или "RUB"
|
||||
- value: Decimal в выбранной валюте
|
||||
text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
|
||||
currency: "USD" или "RUB"
|
||||
value: Decimal в выбранной валюте
|
||||
"""
|
||||
row = await db_session.execute(
|
||||
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.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW, MULTICURRENCY_ENABLE, PROVIDERS_ENABLED
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW
|
||||
from core.bootstrap import PAYMENTS_CONFIG, MONEY_CONFIG
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers import buttons as btn
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from handlers.payments.currency_flow import (
|
||||
build_currency_choice_kb,
|
||||
shortfall_lead_text,
|
||||
currency_label,
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
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(
|
||||
provider: str,
|
||||
callback_query: CallbackQuery,
|
||||
@@ -32,36 +45,37 @@ async def _run_provider_flow(
|
||||
) -> bool:
|
||||
import importlib
|
||||
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
up = provider.upper()
|
||||
cfg = (PROVIDERS.get(up) or {})
|
||||
fast_name = cfg.get("fast")
|
||||
if not fast_name:
|
||||
payment_config = await get_payment_providers_config()
|
||||
providers_map = await get_providers_with_hooks(payment_config)
|
||||
provider_upper = provider.upper()
|
||||
cfg = providers_map.get(provider_upper) or {}
|
||||
fast_handler_name = cfg.get("fast")
|
||||
if not fast_handler_name:
|
||||
return False
|
||||
|
||||
module_name_from_config = cfg.get("module")
|
||||
if module_name_from_config:
|
||||
module_name = f"handlers.payments.{module_name_from_config}.handlers"
|
||||
else:
|
||||
module_name = f"handlers.payments.{up.lower()}.handlers"
|
||||
module_name = f"handlers.payments.{provider_upper.lower()}.handlers"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
func = getattr(module, fast_name)
|
||||
except Exception as e:
|
||||
logger.error(f"[FAST_FLOW] Импорт {up}.{fast_name} из {module_name} не удался: {e}")
|
||||
func = getattr(module, fast_handler_name)
|
||||
except Exception as error:
|
||||
logger.error(f"[FAST_FLOW] Импорт {provider_upper}.{fast_handler_name} из {module_name} не удался: {error}")
|
||||
return False
|
||||
|
||||
try:
|
||||
if up == "STARS":
|
||||
if provider_upper == "STARS":
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {e}")
|
||||
except Exception as error:
|
||||
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {error}")
|
||||
await func(callback_query, session)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[FAST_FLOW] Ошибка при вызове {up}.{fast_name}(): {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"[FAST_FLOW] Ошибка при вызове {provider_upper}.{fast_handler_name}(): {error}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -80,137 +94,170 @@ async def try_fast_payment_flow(
|
||||
if not USE_NEW_PAYMENT_FLOW:
|
||||
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 = (
|
||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
[USE_NEW_PAYMENT_FLOW]
|
||||
if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(provider) for provider in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
)
|
||||
providers = [
|
||||
p for p in providers
|
||||
if (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
||||
provider
|
||||
for provider in providers
|
||||
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:
|
||||
return False
|
||||
|
||||
if len(providers) == 1:
|
||||
up = providers[0].upper()
|
||||
cfg = PROVIDERS.get(up) or {}
|
||||
if len(providers) == 1 and not multicurrency_enabled:
|
||||
single_provider = providers[0].upper()
|
||||
cfg = providers_map.get(single_provider) or {}
|
||||
currency = cfg.get("currency")
|
||||
if 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 False
|
||||
|
||||
if MULTICURRENCY_ENABLE:
|
||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
||||
show_tribute = bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
||||
kb = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
|
||||
lead = await shortfall_lead_text(
|
||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
||||
if multicurrency_enabled:
|
||||
show_stars = bool((providers_map.get("STARS") or {}).get("enabled"))
|
||||
show_tribute = bool((providers_map.get("TRIBUTE") or {}).get("enabled"))
|
||||
keyboard = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
|
||||
lead_text = await shortfall_lead_text(
|
||||
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(
|
||||
temp_key=temp_key,
|
||||
temp_payload=temp_payload,
|
||||
required_amount=required_amount,
|
||||
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
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for p in providers:
|
||||
up = p.upper()
|
||||
btn_text = getattr(btn, up, up)
|
||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{up}"))
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
for provider in providers:
|
||||
provider_upper = provider.upper()
|
||||
button_text = getattr(btn, provider_upper, provider_upper)
|
||||
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(
|
||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
||||
lead_text = await shortfall_lead_text(
|
||||
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 edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=f"{lead}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
||||
reply_markup=kb.as_markup(),
|
||||
text=f"{lead_text}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("choose_payment_currency|"))
|
||||
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]
|
||||
data = await state.get_data()
|
||||
|
||||
providers = data.get("fastflow_providers") or (
|
||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
[USE_NEW_PAYMENT_FLOW]
|
||||
if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(provider) for provider in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
)
|
||||
filtered = [
|
||||
p.upper() for p in providers
|
||||
if (PROVIDERS.get(str(p).upper()) or {}).get("currency") == currency
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
||||
provider_upper
|
||||
for provider_upper in (provider.upper() for provider in providers)
|
||||
if (providers_map.get(provider_upper) or {}).get("currency") == currency
|
||||
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)
|
||||
|
||||
if not filtered:
|
||||
kb = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
||||
keyboard = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
|
||||
reply_markup=kb.as_markup(),
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if len(filtered) == 1:
|
||||
only = filtered[0]
|
||||
if await _run_provider_flow(only, callback_query, session, state, data.get("required_amount")):
|
||||
only_provider = filtered[0]
|
||||
if await _run_provider_flow(only_provider, callback_query, session, state, data.get("required_amount")):
|
||||
return
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Этот способ временно недоступен.",
|
||||
reply_markup=InlineKeyboardBuilder().row(
|
||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
||||
).as_markup(),
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for p in filtered:
|
||||
btn_text = getattr(btn, p, p)
|
||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{p}"))
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
for provider_upper in filtered:
|
||||
button_text = getattr(btn, provider_upper, provider_upper)
|
||||
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,
|
||||
callback_query.from_user.id,
|
||||
data.get("required_amount"),
|
||||
getattr(callback_query.from_user, "language_code", None),
|
||||
force_currency=currency,
|
||||
)
|
||||
text = f"{lead}.\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())
|
||||
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=keyboard.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("choose_payment_provider|"))
|
||||
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()
|
||||
cfg = PROVIDERS.get(provider) or {}
|
||||
cfg = providers_map.get(provider) or {}
|
||||
if not cfg.get("fast") or not cfg.get("enabled", True):
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Этот способ временно недоступен.",
|
||||
reply_markup=InlineKeyboardBuilder().row(
|
||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
||||
).as_markup(),
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
Binary file not shown.
+111
-44
@@ -8,87 +8,129 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import MULTICURRENCY_ENABLE, DONATIONS_ENABLE, PROVIDERS_ENABLED
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
|
||||
from config import DONATIONS_ENABLE
|
||||
from core.bootstrap import PAYMENTS_CONFIG, MONEY_CONFIG, BUTTONS_CONFIG
|
||||
from database import get_last_payments
|
||||
from database.models import User
|
||||
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.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.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
|
||||
|
||||
|
||||
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")
|
||||
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 = []
|
||||
|
||||
for key, cfg in PROVIDERS.items():
|
||||
for key, cfg in providers_with_hooks.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
val = cfg.get("value")
|
||||
if not val:
|
||||
handler_callback_data = cfg.get("value")
|
||||
if not handler_callback_data:
|
||||
continue
|
||||
fn = globals().get(f"process_callback_{val}")
|
||||
if callable(fn):
|
||||
payment_handlers.append(fn)
|
||||
handler = globals().get(f"process_callback_{handler_callback_data}")
|
||||
if callable(handler):
|
||||
payment_handlers.append(handler)
|
||||
|
||||
module_buttons = await run_hooks("pay_menu_buttons", chat_id=callback_query.from_user.id, admin=False, session=session)
|
||||
has_extra_menu_items = bool(module_buttons) or bool(DONATIONS_ENABLE) or bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
|
||||
module_buttons = await run_hooks(
|
||||
"pay_menu_buttons",
|
||||
chat_id=callback_query.from_user.id,
|
||||
admin=False,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if MULTICURRENCY_ENABLE:
|
||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
||||
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)
|
||||
donations_enabled = bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE))
|
||||
has_extra_menu_items = bool(module_buttons) or donations_enabled or bool(
|
||||
(providers_with_hooks.get("TRIBUTE") or {}).get("enabled")
|
||||
)
|
||||
|
||||
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(
|
||||
target_message=callback_query.message,
|
||||
text=FAST_PAY_CHOOSE_CURRENCY,
|
||||
reply_markup=kb.as_markup()
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
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:
|
||||
return await payment_handlers[0](callback_query, state, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for key, cfg in PROVIDERS.items():
|
||||
for key, cfg in providers_with_hooks.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
text = getattr(btn, key, key)
|
||||
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 = insert_hook_buttons(builder, module_buttons)
|
||||
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:
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
b = InlineKeyboardBuilder()
|
||||
for key, cfg in PROVIDERS.items():
|
||||
providers_config = await get_payment_providers_config()
|
||||
providers_with_hooks = await get_providers_with_hooks(providers_config)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for key, cfg in providers_with_hooks.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
if cfg.get("currency") != currency:
|
||||
continue
|
||||
text = getattr(btn, key, key)
|
||||
b.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||
return b
|
||||
builder.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||
return builder
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
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)
|
||||
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
donations_enabled = bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE))
|
||||
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.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")
|
||||
@@ -129,7 +181,13 @@ async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance_text)
|
||||
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")
|
||||
@@ -149,25 +207,35 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
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>"
|
||||
else:
|
||||
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")
|
||||
async def back_to_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
||||
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)
|
||||
kb.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_pay"))
|
||||
providers_config = await get_payment_providers_config()
|
||||
providers_with_hooks = await get_providers_with_hooks(providers_config)
|
||||
|
||||
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)
|
||||
keyboard.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_pay"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_tribute")
|
||||
async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
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
|
||||
|
||||
PROVIDERS_BASE: Dict[str, dict] = {
|
||||
|
||||
PROVIDERS_BASE: dict[str, dict[str, Any]] = {
|
||||
"YOOKASSA": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_yookassa",
|
||||
@@ -30,7 +32,7 @@ PROVIDERS_BASE: Dict[str, dict] = {
|
||||
"module": "kassai",
|
||||
},
|
||||
"TRIBUTE": {
|
||||
"currency": "RUB",
|
||||
"currency": "RUB+USD",
|
||||
"value": "pay_tribute",
|
||||
"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]:
|
||||
out = get_providers(flags)
|
||||
results = await run_hooks("providers_config", providers=out, flags=flags)
|
||||
for r in results:
|
||||
if not isinstance(r, dict):
|
||||
def get_providers(flags: dict[str, bool]) -> dict[str, dict[str, Any]]:
|
||||
providers: dict[str, dict[str, Any]] = {}
|
||||
for name, base in PROVIDERS_BASE.items():
|
||||
cfg = dict(base)
|
||||
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
|
||||
for name, patch in r.items():
|
||||
for name, patch in result.items():
|
||||
if patch is None:
|
||||
out.pop(name, None)
|
||||
providers.pop(name, None)
|
||||
elif isinstance(patch, dict):
|
||||
base = dict(out.get(name, {}))
|
||||
base = dict(providers.get(name, {}))
|
||||
base.update(patch)
|
||||
out[name] = base
|
||||
return out
|
||||
providers[name] = base
|
||||
return providers
|
||||
|
||||
Binary file not shown.
+36
-17
@@ -15,6 +15,7 @@ from config import (
|
||||
SHOW_START_MENU_ONCE,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||
from database import get_balance, get_key_count, get_trial
|
||||
from handlers.buttons import (
|
||||
ABOUT_VPN,
|
||||
@@ -68,9 +69,18 @@ async def process_callback_view_profile(
|
||||
)
|
||||
balance_rub = balance_rub or 0
|
||||
|
||||
fmt_task = asyncio.create_task(format_for_user(session, chat_id, balance_rub, getattr(user, "language_code", None)))
|
||||
profile_menu_task = asyncio.create_task(run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session))
|
||||
profile_text_task = asyncio.create_task(
|
||||
balance_text_task = asyncio.create_task(
|
||||
format_for_user(
|
||||
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(
|
||||
"profile_text",
|
||||
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 += 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:
|
||||
profile_message = text_hooks[0]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
trial_time_disabled = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||
|
||||
if key_count > 0:
|
||||
subs_label = MY_SUB if key_count == 1 else MY_SUBS
|
||||
builder.row(InlineKeyboardButton(text=subs_label, callback_data="view_keys"))
|
||||
elif trial_status == 0 and not TRIAL_TIME_DISABLE:
|
||||
subscriptions_button_text = MY_SUB if key_count == 1 else MY_SUBS
|
||||
builder.row(InlineKeyboardButton(text=subscriptions_button_text, callback_data="view_keys"))
|
||||
elif trial_status == 0 and not trial_time_disabled:
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
else:
|
||||
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"))
|
||||
|
||||
extra_buttons = []
|
||||
if REFERRAL_BUTTON:
|
||||
if BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLE", REFERRAL_BUTTON):
|
||||
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"))
|
||||
if extra_buttons:
|
||||
builder.row(*extra_buttons)
|
||||
|
||||
module_buttons = await profile_menu_task
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
profile_menu_buttons = await profile_menu_buttons_task
|
||||
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"))
|
||||
|
||||
if admin:
|
||||
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"))
|
||||
else:
|
||||
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 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 (
|
||||
add_referral,
|
||||
add_user,
|
||||
@@ -69,40 +70,42 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery, ses
|
||||
if isinstance(value, float):
|
||||
bonuses_lines.append(f"{level} уровень: 🌟 {int(value * 100)}% бонуса")
|
||||
else:
|
||||
value_txt = await format_for_user(session, chat_id, value, language_code)
|
||||
bonuses_lines.append(f"{level} уровень: 💸 {value_txt} бонуса")
|
||||
value_text = await format_for_user(session, chat_id, value, language_code)
|
||||
bonuses_lines.append(f"{level} уровень: 💸 {value_text} бонуса")
|
||||
bonuses_block = "\n".join(bonuses_lines)
|
||||
|
||||
details_lines = []
|
||||
for level, stats in referral_stats["referrals_by_level"].items():
|
||||
bonus_value = REFERRAL_BONUS_PERCENTAGES.get(level)
|
||||
if isinstance(bonus_value, float):
|
||||
bonus_str = f"{int(bonus_value * 100)}%"
|
||||
bonus_text = f"{int(bonus_value * 100)}%"
|
||||
else:
|
||||
bonus_str = await format_for_user(session, chat_id, bonus_value, language_code)
|
||||
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_str}")
|
||||
bonus_text = await format_for_user(session, chat_id, bonus_value, language_code)
|
||||
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_text}")
|
||||
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(
|
||||
referral_link=referral_link,
|
||||
bonuses_block=bonuses_block,
|
||||
total_referrals=referral_stats["total_referrals"],
|
||||
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")
|
||||
|
||||
inline_mode_enabled = bool(MODES_CONFIG.get("INLINE_MODE_ENABLED", INLINE_MODE))
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
if inline_mode_enabled:
|
||||
builder.button(text=INVITE, switch_inline_query="invite")
|
||||
else:
|
||||
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
|
||||
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}")
|
||||
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=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
@@ -143,7 +146,10 @@ async def inline_referral_handler(inline_query: InlineQuery, session: AsyncSessi
|
||||
id=str(index),
|
||||
title=title,
|
||||
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(),
|
||||
)
|
||||
)
|
||||
@@ -161,14 +167,14 @@ async def show_referral_qr(callback_query: CallbackQuery):
|
||||
qr.add_data(referral_link)
|
||||
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()
|
||||
img.save(buffer, format="PNG")
|
||||
image.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
with open(qr_path, "wb") as file:
|
||||
file.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||
@@ -183,8 +189,8 @@ async def show_referral_qr(callback_query: CallbackQuery):
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {error}", exc_info=True)
|
||||
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
|
||||
rows = ""
|
||||
for i, row in enumerate(top_referrals, 1):
|
||||
tg_id = str(row.referrer_tg_id)
|
||||
for index, row in enumerate(top_referrals, 1):
|
||||
referrer_id = str(row.referrer_tg_id)
|
||||
count = row.referral_count
|
||||
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
|
||||
rows += f"{i}. {display_id} - {count} чел.\n"
|
||||
display_id = referrer_id if is_admin else f"{referrer_id[:5]}*****"
|
||||
rows += f"{index}. {display_id} - {count} чел.\n"
|
||||
|
||||
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
|
||||
|
||||
@@ -290,11 +296,11 @@ async def handle_referral_link(
|
||||
referrer_tg_id,
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {error}")
|
||||
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {error}")
|
||||
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
|
||||
|
||||
+42
-14
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
@@ -21,6 +22,7 @@ from config import (
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||
from database import (
|
||||
add_user,
|
||||
get_coupon_by_code,
|
||||
@@ -41,12 +43,12 @@ from handlers.buttons import (
|
||||
)
|
||||
from handlers.captcha import generate_captcha
|
||||
from handlers.coupons import activate_coupon
|
||||
from handlers.payments.gift import handle_gift_link
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.instructions.instructions import send_instructions
|
||||
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.payments.gift import handle_gift_link
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.refferal import invite_handler
|
||||
from handlers.instructions.instructions import send_instructions
|
||||
from handlers.texts import (
|
||||
NOT_SUBSCRIBED_YET_MSG,
|
||||
SUBSCRIPTION_CHECK_ERROR_MSG,
|
||||
@@ -71,17 +73,29 @@ processing_gifts = set()
|
||||
@router.message(Command("start"))
|
||||
@router.callback_query(F.data == "start")
|
||||
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
|
||||
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)
|
||||
if exists is None:
|
||||
captcha_data = await generate_captcha(message, state)
|
||||
await edit_or_send_message(message, captcha_data["text"], reply_markup=captcha_data["markup"])
|
||||
return
|
||||
|
||||
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")
|
||||
@@ -174,7 +188,9 @@ async def process_start_logic(
|
||||
if trial_key is not None:
|
||||
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:
|
||||
await process_callback_view_profile(message, state, admin, session)
|
||||
else:
|
||||
@@ -260,9 +276,16 @@ async def show_start_menu(
|
||||
trial_status = trial
|
||||
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 (
|
||||
((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:
|
||||
@@ -270,7 +293,7 @@ async def show_start_menu(
|
||||
if show_profile:
|
||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if CHANNEL_EXISTS:
|
||||
if BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS):
|
||||
kb.row(
|
||||
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_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
|
||||
snap = await get_user_snapshot(session, user_id)
|
||||
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()
|
||||
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=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))
|
||||
|
||||
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]
|
||||
|
||||
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 os
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiofiles
|
||||
|
||||
@@ -9,6 +9,7 @@ from aiogram.types import Message, Update
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import DISABLE_DIRECT_START
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import async_session_maker, check_user_exists
|
||||
from database.models import Coupon, Gift, TrackingSource, User
|
||||
from logger import logger
|
||||
@@ -32,7 +33,8 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: dict[str, 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)
|
||||
|
||||
message: Message | None = getattr(event, "message", None)
|
||||
|
||||
@@ -5,13 +5,11 @@ from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, Update
|
||||
|
||||
from config import ADMIN_ID
|
||||
from core.bootstrap import MANAGEGENT_CONFIG
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
maintenance_mode = False
|
||||
|
||||
|
||||
class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -19,7 +17,8 @@ class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
if not maintenance_mode:
|
||||
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
if not maintenance_enabled:
|
||||
return await handler(event, data)
|
||||
|
||||
user_id = None
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
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.texts import SUBSCRIPTION_REQUIRED_MSG
|
||||
from handlers.utils import edit_or_send_message
|
||||
@@ -23,7 +24,8 @@ class SubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: dict[str, 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)
|
||||
|
||||
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:
|
||||
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 utils.modules_loader import load_module_webhooks
|
||||
|
||||
|
||||
KASSAI_WEBHOOK_PATH = "/kassai/webhook"
|
||||
HELEKET_WEBHOOK_PATH = "/heleket/webhook"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user