API/ remnawave 1.6.12/ admins in database and more

This commit is contained in:
Vladless
2025-06-14 21:16:53 +03:00
parent e7410ae776
commit f0c1f62d62
28 changed files with 1021 additions and 25 deletions
+33
View File
@@ -0,0 +1,33 @@
from typing import AsyncGenerator
import hashlib
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(
select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed)
)
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+22
View File
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from api.routes import users, keys, coupons, servers, tariffs
app = FastAPI(
title="SoloBot API (preAlpha)",
version="0.1",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
@app.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
+124
View File
@@ -0,0 +1,124 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm.attributes import InstrumentedAttribute
from typing import Type, Union, Any
from api.depends import get_session, verify_admin_token
from database.models import Admin
def _cast_identifier_type(field: InstrumentedAttribute, value: Union[int, str]):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def generate_crud_router(
*,
model: Type,
schema_response: Type,
schema_create: Type,
schema_update: Type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete")
) -> APIRouter:
router = APIRouter()
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
return result.scalars().all()
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return obj
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: Union[int, str] = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: schema_create, # type: ignore
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = payload.dict(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return obj
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: schema_update, # type: ignore
value: Union[int, str] = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for k, v in payload.dict(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return obj
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: Union[int, str] = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+49
View File
@@ -0,0 +1,49 @@
from fastapi import Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database.models import Key, Admin
from api.schemas.keys import KeyBase, KeyResponse, KeyUpdate
from api.routes.base_crud import generate_crud_router
from api.depends import get_session, verify_admin_token
from handlers.keys.key_utils import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email"]
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+54
View File
@@ -0,0 +1,54 @@
from fastapi import Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database.models import User, Key
from api.depends import get_session, verify_admin_token
from handlers.keys.key_utils import delete_key_from_cluster
from database import get_servers, delete_user_data
from logger import logger
import asyncio
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"]
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(
select(Key.email, Key.client_id).where(Key.tg_id == tg_id)
)
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
+8
View File
@@ -0,0 +1,8 @@
from .users import UserBase, UserResponse, UserUpdate
from .keys import KeyResponse, KeyDetailsResponse
from .tariffs import TariffResponse, TariffUpdate, TariffBase
from .servers import ServerResponse, ServerBase, ServerUpdate
from .coupons import CouponResponse, CouponUsageResponse, CouponBase, CouponUpdate
from .referrals import ReferralResponse
from .gifts import GiftResponse, GiftUsageResponse
from .misc import ManualBanResponse, TemporaryDataResponse, BlockedUserResponse, TrackingSourceResponse, PaymentResponse, NotificationResponse
+54
View File
@@ -0,0 +1,54 @@
from pydantic import BaseModel, Field, model_validator
from typing import Optional
from datetime import datetime
class CouponBase(BaseModel):
code: str
amount: Optional[int] = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: Optional[int] = Field(default=None)
@model_validator(mode="after")
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
has_amount = getattr(self, "amount", None) is not None
has_days = getattr(self, "days", None) is not None
if has_amount == has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
return self
class CouponResponse(CouponBase):
id: int
class Config:
from_attributes = True
class CouponUpdate(BaseModel):
code: Optional[str] = None
amount: Optional[int] = None
usage_limit: Optional[int] = None
usage_count: Optional[int] = None
is_used: Optional[bool] = None
days: Optional[int] = Field(default=None)
@model_validator(mode="after")
def validate_amount_or_days(self) -> "CouponUpdate":
if self.amount is None and self.days is None:
return self
if self.amount is not None and self.days is not None:
raise ValueError("Specify only one of: 'amount' or 'days'")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
+32
View File
@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
+71
View File
@@ -0,0 +1,71 @@
from pydantic import BaseModel
from typing import Optional
class KeyBase(BaseModel):
tg_id: int
client_id: str
email: Optional[str] = None
created_at: Optional[int] = None
expiry_time: int
key: Optional[str] = None
server_id: Optional[str] = None
remnawave_link: Optional[str] = None
tariff_id: Optional[int] = None
is_frozen: Optional[bool] = False
alias: Optional[str] = None
notified: Optional[bool] = False
notified_24h: Optional[bool] = False
class KeyResponse(KeyBase):
class Config:
from_attributes = True
class KeyDetailsResponse(BaseModel):
key: Optional[str]
remnawave_link: Optional[str]
server_id: Optional[str]
created_at: Optional[int]
expiry_time: Optional[int]
client_id: str
tg_id: int
email: Optional[str]
is_frozen: bool
balance: float
alias: Optional[str]
expiry_date: str
days_left_message: str
link: Optional[str]
cluster_name: Optional[str]
location_name: Optional[str]
tariff_id: Optional[int]
class Config:
from_attributes = True
class KeyUpdate(BaseModel):
email: Optional[str] = None
expiry_time: Optional[int] = None
key: Optional[str] = None
server_id: Optional[str] = None
remnawave_link: Optional[str] = None
tariff_id: Optional[int] = None
is_frozen: Optional[bool] = None
alias: Optional[str] = None
notified: Optional[bool] = None
notified_24h: Optional[bool] = None
class Config:
from_attributes = True
class KeyCreateRequest(BaseModel):
tg_id: int
cluster_id: str
tariff_id: int
email: Optional[str] = None
alias: Optional[str] = None
remnawave_link: Optional[str] = None
+105
View File
@@ -0,0 +1,105 @@
from pydantic import BaseModel
from typing import Optional, Union, Literal
from datetime import datetime
class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: Literal["success", "pending", "failed"]
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
tg_id: int
banned_at: datetime
reason: str
banned_by: int
until: Optional[datetime] = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
tg_id: int
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
tg_id: int
class Config:
from_attributes = True
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
class Config:
from_attributes = True
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: Optional[str] = None
inbound_id: str
panel_type: str
max_keys: Optional[int] = None
tariff_group: str
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: Optional[str] = None
server_name: Optional[str] = None
api_url: Optional[str] = None
subscription_url: Optional[str] = None
inbound_id: Optional[str] = None
panel_type: Optional[str] = None
max_keys: Optional[int] = None
tariff_group: Optional[str] = None
enabled: Optional[bool] = None
class Config:
from_attributes = True
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: Optional[int] = None
device_limit: Optional[int] = None
is_active: bool = True
subgroup_title: Optional[str] = None
class TariffResponse(TariffBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: Optional[str] = None
group_code: Optional[str] = None
duration_days: Optional[int] = None
price_rub: Optional[int] = None
traffic_limit: Optional[int] = None
device_limit: Optional[int] = None
is_active: Optional[bool] = None
subgroup_title: Optional[str] = None
class Config:
from_attributes = True
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
tg_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language_code: Optional[str] = None
is_bot: Optional[bool] = False
balance: float = 0.0
trial: int = 0
source_code: Optional[str] = None
class UserResponse(UserBase):
created_at: Optional[datetime]
updated_at: Optional[datetime]
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language_code: Optional[str] = None
is_bot: Optional[bool] = None
balance: Optional[float] = None
trial: Optional[int] = None
source_code: Optional[str] = None
class Config:
from_attributes = True
+17 -3
View File
@@ -1,7 +1,21 @@
from database.db import engine from database.db import engine, async_session_maker
from database.models import Base from database.models import Base, Admin
from sqlalchemy import select
from config import ADMIN_ID
from datetime import datetime
async def init_db(): async def init_db():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with async_session_maker() as session:
for tg_id in ADMIN_ID:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if not result.scalar_one_or_none():
session.add(Admin(
tg_id=tg_id,
role="superadmin",
description="Imported from config",
added_at=datetime.utcnow()
))
await session.commit()
+13 -8
View File
@@ -12,6 +12,7 @@ from sqlalchemy import (
String, String,
Text, Text,
) )
import secrets
from sqlalchemy.orm import Mapped, declarative_base, mapped_column from sqlalchemy.orm import Mapped, declarative_base, mapped_column
Base = declarative_base() Base = declarative_base()
@@ -57,7 +58,7 @@ class Key(DictLikeMixin, Base):
key = Column(String) key = Column(String)
server_id = Column(String) server_id = Column(String)
remnawave_link = Column(String) remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id")) tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
is_frozen = Column(Boolean, default=False) is_frozen = Column(Boolean, default=False)
alias = Column(String) alias = Column(String)
notified = Column(Boolean, default=False) notified = Column(Boolean, default=False)
@@ -202,11 +203,15 @@ class TrackingSource(DictLikeMixin, Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class Log(DictLikeMixin, Base): class Admin(Base):
__tablename__ = "logs" __tablename__ = "admins"
id = Column(Integer, primary_key=True) tg_id = Column(BigInteger, primary_key=True)
event_type = Column(String) token = Column(String, unique=True, nullable=True)
tg_id = Column(BigInteger) description = Column(String, nullable=True)
details = Column(JSON) role = Column(String, nullable=False, default="admin")
created_at = Column(DateTime, default=datetime.utcnow) added_at = Column(DateTime, default=datetime.utcnow)
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
+9 -3
View File
@@ -1,12 +1,18 @@
from aiogram.filters import BaseFilter from aiogram.filters import BaseFilter
from aiogram.types import Message from aiogram.types import Message
from sqlalchemy import select
from database.models import Admin
from config import ADMIN_ID from database.db import async_session_maker
class IsAdminFilter(BaseFilter): class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool: async def __call__(self, message: Message) -> bool:
try: try:
return message.from_user.id in ADMIN_ID async with async_session_maker() as session:
result = await session.execute(
select(Admin).where(Admin.tg_id == message.from_user.id)
)
admin = result.scalar_one_or_none()
return admin is not None
except Exception: except Exception:
return False return False
+3 -1
View File
@@ -9,7 +9,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
from database.models import Tariff, Gift, GiftUsage from database.models import Tariff, Gift, GiftUsage
from handlers.payments.gift import finalize_gift
router = Router() router = Router()
@@ -67,6 +67,7 @@ async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "gift_limit_unlimited") @router.callback_query(F.data == "gift_limit_unlimited")
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot): async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
data = await state.get_data() data = await state.get_data()
session: AsyncSession = callback.bot["session"] session: AsyncSession = callback.bot["session"]
await state.clear() await state.clear()
@@ -74,6 +75,7 @@ async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot:
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited) @router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot): async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
try: try:
max_usages = int(message.text.strip()) max_usages = int(message.text.strip())
if max_usages <= 0: if max_usages <= 0:
+96 -1
View File
@@ -6,8 +6,15 @@ from middlewares import maintenance
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_management_kb() -> InlineKeyboardMarkup: def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if admin_role == "superadmin":
builder.button(
text="👑 Управление админами",
callback_data=AdminPanelCallback(action="admins").pack(),
)
builder.button( builder.button(
text="💾 Создать резервную копию", text="💾 Создать резервную копию",
callback_data=AdminPanelCallback(action="backups").pack(), callback_data=AdminPanelCallback(action="backups").pack(),
@@ -41,3 +48,91 @@ def build_management_kb() -> InlineKeyboardMarkup:
builder.row(build_admin_back_btn()) builder.row(build_admin_back_btn())
builder.adjust(1) builder.adjust(1)
return builder.as_markup() return builder.as_markup()
def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for tg_id, role in admins:
builder.button(
text=f"🧑 {tg_id} ({role})",
callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
)
builder.button(
text=" Добавить админа",
callback_data=AdminPanelCallback(action="add_admin").pack()
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboardMarkup:
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":
builder.button(
text="🎟 Выпустить токен",
callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="admins").pack()
)
builder.adjust(1)
return builder.as_markup()
def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="👑 superadmin",
callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack()
)
builder.button(
text="🛡 moderator",
callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack()
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
)
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="admins").pack()
)
builder.adjust(1)
return builder.as_markup()
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="📋 Скопировать токен",
switch_inline_query_current_chat=token
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="admins").pack()
)
builder.adjust(1)
return builder.as_markup()
+167 -8
View File
@@ -2,16 +2,19 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy import func, select, update from sqlalchemy import func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import hashlib
from database.models import Key from database.models import Key, Admin
from filters.admin import IsAdminFilter from filters.admin import IsAdminFilter
from logger import logger from logger import logger
from middlewares import maintenance from middlewares import maintenance
from ..panel.keyboard import build_admin_back_kb from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminPanelCallback, build_management_kb from .keyboard import AdminPanelCallback, build_management_kb, build_admins_kb, build_single_admin_menu, build_role_selection_kb, build_admin_back_kb_to_admins, build_token_result_kb
from asyncio import sleep
router = Router() router = Router()
@@ -20,13 +23,26 @@ class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State() waiting_for_new_domain = State()
class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query( @router.callback_query(
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter() AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
) )
async def handle_management(callback_query: CallbackQuery): 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( await callback_query.message.edit_text(
text="🤖 Управление ботом", text="🤖 Управление ботом",
reply_markup=build_management_kb(), reply_markup=build_management_kb(admin.role),
) )
@@ -94,10 +110,153 @@ async def process_new_domain(
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance")) @router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery): async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
maintenance.maintenance_mode = not maintenance.maintenance_mode 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 "выключен" new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True) await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb()) 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> нового админа:")
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="admin",
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
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()
)
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.