runtime settings loaded from DB/ refactoring admin-panel/ minor fixes and improvements

This commit is contained in:
Vladless
2025-11-15 23:14:34 +03:00
parent 76390df4bc
commit 041e1c898d
97 changed files with 8554 additions and 6514 deletions
+3 -131
View File
@@ -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.
+18
View File
@@ -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.
+56
View File
@@ -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)
+59
View File
@@ -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)
+56
View File
@@ -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)
+58
View File
@@ -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)
+58
View File
@@ -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)
+56
View File
@@ -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)
+10
View File
@@ -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)
+37
View File
@@ -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
+2
View File
@@ -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,
)
+15 -31
View File
@@ -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())
+4 -2
View File
@@ -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"]
+18
View File
@@ -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()
+394
View File
@@ -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()
+635
View File
@@ -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"),
)
+483
View File
@@ -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()
+253
View File
@@ -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
+15 -4
View File
@@ -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()
)
],
]
)
+6 -32
View File
@@ -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|"))
+1 -4
View File
@@ -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,9 +37,6 @@ 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)
+12 -2
View File
@@ -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
)
+153
View File
@@ -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()
)
+168
View File
@@ -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())
+79
View File
@@ -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()
+107
View File
@@ -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()
+88
View File
@@ -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
+6 -13
View File
@@ -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(
+52
View File
@@ -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()
-2
View File
@@ -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
+7 -2
View File
@@ -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()
+5 -3
View File
@@ -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
@@ -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()
+14
View File
@@ -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()
+164
View File
@@ -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()
+17
View File
@@ -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)
+212
View File
@@ -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()
+53
View File
@@ -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("Настройка обновлена")
+209
View File
@@ -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),
)
+3 -6
View File
@@ -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>"
)
+9 -2
View File
@@ -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
)
+589
View File
@@ -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(),
)
+180
View File
@@ -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)
+28
View File
@@ -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()
+550
View File
@@ -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}"
)
]
]
),
)
+55
View File
@@ -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
+9 -2
View File
@@ -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)
+225
View File
@@ -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
+131
View File
@@ -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
+596
View File
@@ -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,
)
+24
View File
@@ -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()
+12 -14
View File
@@ -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"))
+6 -14
View File
@@ -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}"))
+11 -6
View File
@@ -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,
+10 -6
View File
@@ -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:
+12 -6
View File
@@ -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")
+36 -42
View File
@@ -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,26 +338,15 @@ 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}"),
)
else:
if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
@@ -368,19 +355,24 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
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")
+9 -6
View File
@@ -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
+6 -2
View File
@@ -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
)
+1 -1
View File
@@ -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 [], {}
+111 -70
View File
@@ -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
+39 -27
View File
@@ -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}")
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("Обработка пользователей с нулевым трафиком завершена.")
+48 -23
View File
@@ -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,19 +156,16 @@ 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
cur = pick_currency(language_code, user_currency=user_currency, force_currency=force_currency)
if cur == "RUB":
val = _round2(Decimal(str(amount_rub)))
else:
val = await convert_from_rub(Decimal(str(amount_rub)), "USD", session=session)
val = await convert_from_rub(Decimal(str(amount_rub)), cur, session=session)
txt = fmt_money(val, cur, language_code)
return txt, cur, val
val = _round2(Decimal(str(amount_rub)))
txt = fmt_money(val, "RUB", language_code)
return txt, "RUB", val
async def money_for_user(
db_session,
@@ -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"),
+122 -75
View File
@@ -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
+111 -44
View File
@@ -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)
+24 -20
View File
@@ -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
+36 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
-2
View File
@@ -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
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -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)
+3 -4
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()}"
+1
View File
@@ -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"