formatting/3x-ui db import/cosmetic fixes

This commit is contained in:
Vladless
2025-07-19 01:14:42 +03:00
parent 8f471a1e43
commit c610fb118e
111 changed files with 2136 additions and 3816 deletions
+5 -6
View File
@@ -1,9 +1,10 @@
from typing import AsyncGenerator
import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database.models import Admin
@@ -24,10 +25,8 @@ async def verify_admin_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)
)
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
return admin
+25 -17
View File
@@ -1,14 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any, Type, Union
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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]):
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
@@ -17,18 +18,19 @@ def _cast_identifier_type(field: InstrumentedAttribute, value: Union[int, str]):
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",
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")
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),
@@ -38,6 +40,7 @@ def generate_crud_router(
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(...),
@@ -51,9 +54,10 @@ def generate_crud_router(
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),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
@@ -66,9 +70,10 @@ def generate_crud_router(
return obj
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: Union[int, str] = Path(..., alias=parameter_name),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
@@ -81,9 +86,10 @@ def generate_crud_router(
return objs
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: schema_create, # type: ignore
payload: schema_create, # type: ignore
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
@@ -97,10 +103,11 @@ def generate_crud_router(
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),
payload: schema_update, # type: ignore
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
@@ -119,9 +126,10 @@ def generate_crud_router(
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),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
+3 -1
View File
@@ -1,8 +1,10 @@
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,
@@ -10,5 +12,5 @@ router: APIRouter = generate_crud_router(
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+7 -6
View File
@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Path, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Gift, GiftUsage, Admin
from api.depends import get_session, verify_admin_token
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
@@ -17,7 +18,7 @@ gift_router = generate_crud_router(
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@@ -41,7 +42,7 @@ gift_usage_router = generate_crud_router(
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
+13 -17
View File
@@ -1,16 +1,16 @@
from fastapi import Depends, HTTPException, Path, Body, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
from database.models import Key, Admin, Tariff
from api.schemas.keys import KeyBase, KeyResponse, KeyUpdate, KeyCreateRequest
from api.routes.base_crud import generate_crud_router
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from handlers.keys.key_utils import delete_key_from_cluster
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.key_utils import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
from handlers.keys.key_utils import renew_key_in_cluster, create_key_on_cluster
router = generate_crud_router(
model=Key,
@@ -19,7 +19,7 @@ router = generate_crud_router(
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"]
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@@ -58,16 +58,12 @@ async def get_router_keys_by_tg_id(
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
tariffs_result = await session.execute(
select(Tariff.id).where(Tariff.group_code == "routers")
)
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids))
)
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
@@ -109,7 +105,7 @@ async def edit_key_by_email(
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True
reset_traffic=True,
)
await session.commit()
@@ -147,4 +143,4 @@ async def create_key_api(
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+36 -24
View File
@@ -1,17 +1,29 @@
from fastapi import APIRouter, Path, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from api.routes.base_crud import generate_crud_router
from database.models import (
Payment, Referral, Notification,
ManualBan, TemporaryData, BlockedUser, TrackingSource, Admin
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import (
PaymentResponse, ReferralResponse, NotificationResponse,
ManualBanResponse, TemporaryDataResponse, BlockedUserResponse,
TrackingSourceResponse
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database.models import (
Admin,
BlockedUser,
ManualBan,
Notification,
Payment,
Referral,
TemporaryData,
TrackingSource,
)
router = APIRouter()
@@ -22,11 +34,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
@@ -50,11 +62,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="referred_tg_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/referrals",
tags=["Referrals"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
@@ -64,11 +76,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
@@ -79,11 +91,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
@@ -93,11 +105,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
@@ -107,11 +119,11 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
@@ -121,9 +133,9 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"]
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)]
dependencies=[Depends(verify_admin_token)],
)
+10 -11
View File
@@ -1,10 +1,12 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import ReferralResponse
from database.models import Referral, Admin
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from api.depends import get_session, verify_admin_token
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
@@ -12,7 +14,7 @@ router = generate_crud_router(
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"]
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@@ -21,17 +23,14 @@ async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session)
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Referral).where(
Referral.referrer_tg_id == referrer_tg_id,
Referral.referred_tg_id == referred_tg_id
)
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
return {"status": "deleted_one"}
+3 -1
View File
@@ -1,8 +1,10 @@
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,
@@ -10,5 +12,5 @@ router: APIRouter = generate_crud_router(
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+3 -1
View File
@@ -1,8 +1,10 @@
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,
@@ -10,5 +12,5 @@ router: APIRouter = generate_crud_router(
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+9 -9
View File
@@ -1,14 +1,16 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
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 database import delete_user_data, get_servers
from database.models import Key, User
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(
@@ -17,7 +19,7 @@ router = generate_crud_router(
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"]
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
)
@@ -28,9 +30,7 @@ async def delete_user(
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(
select(Key.email, Key.client_id).where(Key.tg_id == tg_id)
)
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():
+14 -7
View File
@@ -1,8 +1,15 @@
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 .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from .keys import KeyDetailsResponse, KeyResponse
from .misc import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from .referrals import ReferralResponse
from .gifts import GiftResponse, GiftUsageResponse, GiftUpdate, GiftBase
from .misc import ManualBanResponse, TemporaryDataResponse, BlockedUserResponse, TrackingSourceResponse, PaymentResponse, NotificationResponse
from .servers import ServerBase, ServerResponse, ServerUpdate
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
+12 -11
View File
@@ -1,15 +1,16 @@
from pydantic import BaseModel, Field, model_validator
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, model_validator
class CouponBase(BaseModel):
code: str
amount: Optional[int] = Field(default=None)
amount: int | None = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: Optional[int] = Field(default=None)
days: int | None = Field(default=None)
@model_validator(mode="after")
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
@@ -31,12 +32,12 @@ class CouponResponse(CouponBase):
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)
code: str | None = None
amount: int | None = None
usage_limit: int | None = None
usage_count: int | None = None
is_used: bool | None = None
days: int | None = Field(default=None)
@model_validator(mode="after")
def validate_amount_or_days(self) -> "CouponUpdate":
@@ -53,4 +54,4 @@ class CouponUsageResponse(BaseModel):
used_at: datetime
class Config:
from_attributes = True
from_attributes = True
+17 -16
View File
@@ -1,18 +1,19 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
selected_months: Optional[int] = None
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: Optional[bool] = False
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
is_unlimited: bool | None = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
@@ -33,14 +34,14 @@ class GiftUsageResponse(BaseModel):
class GiftUpdate(BaseModel):
recipient_tg_id: Optional[int] = None
selected_months: Optional[int] = None
expiry_time: Optional[datetime] = None
gift_link: Optional[str] = None
is_used: Optional[bool] = None
is_unlimited: Optional[bool] = None
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
tariff_id: int | None = None
class Config:
from_attributes = True
from_attributes = True
+40 -39
View File
@@ -1,21 +1,22 @@
from pydantic import BaseModel, Field
from typing import Optional
from pydantic import BaseModel, Field
class KeyBase(BaseModel):
tg_id: int
client_id: str
email: Optional[str] = None
created_at: Optional[int] = None
email: str | None = None
created_at: int | None = 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
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = False
alias: str | None = None
notified: bool | None = False
notified_24h: bool | None = False
class KeyResponse(KeyBase):
@@ -24,39 +25,39 @@ class KeyResponse(KeyBase):
class KeyDetailsResponse(BaseModel):
key: Optional[str]
remnawave_link: Optional[str]
server_id: Optional[str]
created_at: Optional[int]
expiry_time: Optional[int]
key: str | None
remnawave_link: str | None
server_id: str | None
created_at: int | None
expiry_time: int | None
client_id: str
tg_id: int
email: Optional[str]
email: str | None
is_frozen: bool
balance: float
alias: Optional[str]
alias: str | None
expiry_date: str
days_left_message: str
link: Optional[str]
cluster_name: Optional[str]
location_name: Optional[str]
tariff_id: Optional[int]
link: str | None
cluster_name: str | None
location_name: str | None
tariff_id: int | None
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
email: str | None = None
expiry_time: int | None = None
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = None
alias: str | None = None
notified: bool | None = None
notified_24h: bool | None = None
class Config:
from_attributes = True
@@ -68,10 +69,10 @@ class KeyCreateRequest(BaseModel):
tariff_id: int = Field(..., description="ID тарифа из базы данных")
client_id: str = Field(..., description="UUID клиента (уникальный)")
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
email: Optional[str] = Field(None, description="Условное имя подписки")
alias: Optional[str] = Field(None, description="пользовательское имя")
remnawave_link: Optional[str] = Field(None, description="Ссылка на подписку Remnawave")
hwid_limit: Optional[int] = Field(None, description="Ограничение по HWID")
traffic_limit_bytes: Optional[int] = Field(None, description="Ограничение трафика в байтах")
is_trial: Optional[bool] = Field(False, description="Флаг триального ключа")
email: str | None = Field(None, description="Условное имя подписки")
alias: str | None = Field(None, description="пользовательское имя")
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
is_trial: bool | None = Field(False, description="Флаг триального ключа")
+7 -6
View File
@@ -1,6 +1,7 @@
from pydantic import BaseModel
from typing import Optional, Union, Literal
from datetime import datetime
from typing import Literal, Optional, Union
from pydantic import BaseModel
class PaymentBase(BaseModel):
@@ -38,14 +39,14 @@ class NotificationResponse(BaseModel):
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
recipient_tg_id: int | None = 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
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
@@ -70,7 +71,7 @@ class ManualBanResponse(BaseModel):
banned_at: datetime
reason: str
banned_by: int
until: Optional[datetime] = None
until: datetime | None = None
class Config:
from_attributes = True
+1 -1
View File
@@ -7,4 +7,4 @@ class ReferralResponse(BaseModel):
reward_issued: bool = False
class Config:
from_attributes = True
from_attributes = True
+15 -14
View File
@@ -1,16 +1,17 @@
from pydantic import BaseModel
from typing import Optional
from pydantic import BaseModel
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: Optional[str] = None
subscription_url: str | None = None
inbound_id: str
panel_type: str
max_keys: Optional[int] = None
tariff_group: Optional[str] = ""
max_keys: int | None = None
tariff_group: str | None = ""
enabled: bool = True
@@ -22,15 +23,15 @@ class ServerResponse(ServerBase):
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
cluster_name: str | None = None
server_name: str | None = None
api_url: str | None = None
subscription_url: str | None = None
inbound_id: str | None = None
panel_type: str | None = None
max_keys: int | None = None
tariff_group: str | None = None
enabled: bool | None = None
class Config:
from_attributes = True
from_attributes = True
+16 -16
View File
@@ -1,7 +1,7 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TariffBase(BaseModel):
@@ -9,30 +9,30 @@ class TariffBase(BaseModel):
group_code: str
duration_days: int
price_rub: int
traffic_limit: Optional[int] = None
device_limit: Optional[int] = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool = True
subgroup_title: Optional[str] = None
subgroup_title: str | None = None
class TariffResponse(TariffBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime | None = None
updated_at: datetime | None = 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
name: str | None = None
group_code: str | None = None
duration_days: int | None = None
price_rub: int | None = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
class Config:
from_attributes = True
from_attributes = True
+19 -18
View File
@@ -1,37 +1,38 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
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
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float = 0.0
trial: int = 0
source_code: Optional[str] = None
source_code: str | None = None
class UserResponse(UserBase):
created_at: Optional[datetime]
updated_at: Optional[datetime]
created_at: datetime | None
updated_at: datetime | None
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
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = None
balance: float | None = None
trial: int | None = None
source_code: str | None = None
class Config:
from_attributes = True
+18 -8
View File
@@ -1,16 +1,28 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram.types import BufferedInputFile
from aiogram import Bot
from aiogram.types import BufferedInputFile
from bot import bot
from config import (
ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT,
BACKUP_SEND_MODE, BACKUP_CHANNEL_ID, BACKUP_CHANNEL_THREAD_ID, BACKUP_OTHER_BOT_TOKEN, BACKUP_CAPTION
ADMIN_ID,
BACKUP_CAPTION,
BACKUP_CHANNEL_ID,
BACKUP_CHANNEL_THREAD_ID,
BACKUP_OTHER_BOT_TOKEN,
BACKUP_SEND_MODE,
BACK_DIR,
DB_NAME,
DB_PASSWORD,
DB_USER,
PG_HOST,
PG_PORT,
)
from logger import logger
@@ -146,9 +158,7 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
async def send_default():
for admin_id in ADMIN_ID:
try:
await bot.send_document(
chat_id=admin_id, document=backup_input_file
)
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
@@ -169,7 +179,7 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
logger.error("BACKUP_CHANNEL_ID не задан для режима 'channel', fallback на default")
await send_default()
return
send_kwargs = dict(chat_id=channel_id, document=backup_input_file)
send_kwargs = {"chat_id": channel_id, "document": backup_input_file}
if thread_id:
send_kwargs["message_thread_id"] = int(thread_id)
if BACKUP_CAPTION:
@@ -190,7 +200,7 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
try:
for admin_id in ADMIN_ID:
try:
send_kwargs = dict(chat_id=admin_id, document=backup_input_file)
send_kwargs = {"chat_id": admin_id, "document": backup_input_file}
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await other_bot.send_document(**send_kwargs)
+25 -36
View File
@@ -1,5 +1,6 @@
import traceback
import os
import subprocess
import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
@@ -9,12 +10,12 @@ from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
import subprocess
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
@@ -32,32 +33,25 @@ def get_git_commit_number() -> str:
env["GIT_WORK_TREE"] = cwd
try:
local_number = subprocess.check_output(
["git", "rev-list", "--count", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
local_number = (
subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env).decode().strip()
)
local_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
local_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, env=env).decode().strip()
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
branch = (
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env).decode().strip()
)
if branch == "HEAD":
describe = subprocess.check_output(
["git", "describe", "--tags", "--exact-match"],
cwd=cwd,
env=env,
stderr=subprocess.DEVNULL
).decode().strip()
describe = (
subprocess.check_output(
["git", "describe", "--tags", "--exact-match"], cwd=cwd, env=env, stderr=subprocess.DEVNULL
)
.decode()
.strip()
)
if describe.startswith("v") or "release" in describe.lower():
branch = "main"
@@ -71,29 +65,25 @@ def get_git_commit_number() -> str:
try:
remote_commit = subprocess.check_output(
["git", "ls-remote", "origin", f"refs/heads/{branch}"],
cwd=cwd,
env=env
["git", "ls-remote", "origin", f"refs/heads/{branch}"], cwd=cwd, env=env
).decode()
remote_hash = remote_commit.split()[0]
remote_number = subprocess.check_output(
["git", "rev-list", "--count", remote_hash],
cwd=cwd,
env=env
).decode().strip()
remote_number = (
subprocess.check_output(["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env).decode().strip()
)
if local_hash == remote_hash:
return "\n(Актуальная версия)"
return (
f"\n(commit <a href=\"{repo_url}/commit/{local_hash}\">"
f'\n(commit <a href="{repo_url}/commit/{local_hash}">'
f"#{local_number}</a> / actual commit "
f"<a href=\"{repo_url}/commit/{remote_hash}\">#{remote_number}</a>)"
f'<a href="{repo_url}/commit/{remote_hash}">#{remote_number}</a>)'
)
except Exception:
return f"\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
return "\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
version = f"v4.4-b030735{get_git_commit_number()}"
@@ -113,8 +103,7 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid"
in error_message
"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
):
+26 -56
View File
@@ -4,6 +4,7 @@ import subprocess
import sys
import requests
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
@@ -11,6 +12,7 @@ from rich.text import Text
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
@@ -30,13 +32,9 @@ IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print(
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
)
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print(
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
)
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
@@ -44,9 +42,7 @@ SERVICE_NAME = BOT_SERVICE
def is_service_exists(service_name):
result = subprocess.run(
["systemctl", "list-unit-files", service_name], capture_output=True, text=True
)
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
@@ -79,9 +75,7 @@ def fix_permissions():
try:
user = os.getenv("SUDO_USER") or os.getenv("USER")
if user:
subprocess.run(
["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True
)
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
@@ -147,9 +141,7 @@ def install_dependencies():
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print(
"[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]"
)
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
subprocess.run("python3 -m venv venv", shell=True, check=True)
subprocess.run(
@@ -202,9 +194,7 @@ def update_from_beta():
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(
f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]"
)
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
return
@@ -257,9 +247,7 @@ def update_from_beta():
def update_from_release():
update_cli_launcher()
if not Confirm.ask(
"[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"
):
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
@@ -275,9 +263,7 @@ def update_from_release():
install_rsync_if_needed()
try:
response = requests.get(
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
)
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
@@ -294,9 +280,7 @@ def update_from_release():
)
tag_name = tag_choices[int(selected) - 1]
if not Confirm.ask(
f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"
):
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
return
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
@@ -336,9 +320,7 @@ def update_from_release():
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
console.print(
"[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]"
)
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
@@ -358,9 +340,7 @@ def show_update_menu():
def show_menu():
table = Table(
title="Solobot CLI v0.2.0", title_style="bold magenta", header_style="bold blue"
)
table = Table(title="Solobot CLI v0.2.0", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
@@ -383,9 +363,7 @@ def update_cli_launcher():
response = requests.get(url, timeout=10)
if response.status_code == 200:
with open(
os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8"
) as f:
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f:
f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
@@ -411,39 +389,31 @@ def main():
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if Confirm.ask(
"[green]Вы действительно хотите запустить main.py вручную?[/green]"
):
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if Confirm.ask(
"[yellow]Вы действительно хотите перезапустить бота?[/yellow]"
):
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "4":
if is_service_exists(SERVICE_NAME):
if Confirm.ask(
"[red]Вы уверены, что хотите остановить бота?[/red]"
):
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "5":
if is_service_exists(SERVICE_NAME):
subprocess.run(
[
"sudo",
"journalctl",
"-u",
SERVICE_NAME,
"-n",
"80",
"--no-pager",
]
)
subprocess.run([
"sudo",
"journalctl",
"-u",
SERVICE_NAME,
"-n",
"80",
"--no-pager",
])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "6":
+1 -5
View File
@@ -5,10 +5,6 @@ from database.models import BlockedUser
async def create_blocked_user(session: AsyncSession, tg_id: int):
stmt = (
insert(BlockedUser)
.values(tg_id=tg_id)
.on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
)
stmt = insert(BlockedUser).values(tg_id=tg_id).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await session.execute(stmt)
await session.commit()
+6 -18
View File
@@ -8,9 +8,7 @@ from database.models import Coupon, CouponUsage
from logger import logger
async def create_coupon(
session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None
) -> bool:
async def create_coupon(session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
@@ -42,9 +40,7 @@ async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
return result.scalar_one_or_none()
async def get_all_coupons(
session: AsyncSession, page: int = 1, per_page: int = 10
) -> dict:
async def get_all_coupons(session: AsyncSession, page: int = 1, per_page: int = 10) -> dict:
offset = (page - 1) * per_page
stmt = select(Coupon).order_by(Coupon.id.desc()).offset(offset).limit(per_page)
@@ -81,9 +77,7 @@ async def delete_coupon(session: AsyncSession, code: str) -> bool:
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
try:
stmt = insert(CouponUsage).values(
coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow()
)
stmt = insert(CouponUsage).values(coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow())
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
@@ -92,12 +86,8 @@ async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: in
await session.rollback()
async def check_coupon_usage(
session: AsyncSession, coupon_id: int, user_id: int
) -> bool:
stmt = select(CouponUsage).where(
CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id
)
async def check_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int) -> bool:
stmt = select(CouponUsage).where(CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
@@ -109,9 +99,7 @@ async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case(
(Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False
),
is_used=case((Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False),
)
)
await session.commit()
+2 -3
View File
@@ -3,10 +3,9 @@ from sqlalchemy.orm import declarative_base
from config import DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
async_session_maker = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
Base = declarative_base()
+1 -5
View File
@@ -8,11 +8,7 @@ async def get_hot_leads(session: AsyncSession):
"""
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
"""
subquery = (
select(Key.tg_id)
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
.distinct()
)
subquery = select(Key.tg_id).where(Key.expiry_time > func.extract("epoch", func.now()) * 1000).distinct()
stmt = (
select(Payment.tg_id)
+115
View File
@@ -0,0 +1,115 @@
import json
import sqlite3
import time
from datetime import datetime
from itertools import cycle
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Server, User
async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple[int, int]:
imported = 0
skipped = 0
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
clusters = [row[0] for row in result.fetchall()]
if not clusters:
raise RuntimeError("❌ Не найдено доступных кластеров для 3x-ui")
cluster_cycle = cycle(clusters)
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT id, remark, settings FROM inbounds")
inbounds = cursor.fetchall()
except Exception as e:
raise RuntimeError(f"Не удалось прочитать SQLite: {e}")
finally:
conn.close()
parsed_clients = []
for inbound_id, _remark, settings_raw in inbounds:
try:
settings = json.loads(settings_raw)
clients = settings.get("clients", [])
for c in clients:
expiry = c.get("expiryTime")
c["expiryTime"] = int(float(expiry)) if expiry else 0
c["limitIp"] = int(c.get("limitIp", 0) or 0)
c["inbound_id"] = inbound_id
parsed_clients.append(c)
except Exception:
continue
now_ts = int(time.time() * 1000)
for c in parsed_clients:
tg_id = c.get("tgId")
client_id = str(c.get("id"))
email = c.get("email")
expiry_time = int(c.get("expiryTime") or now_ts)
created_at = now_ts
server_id = next(cluster_cycle)
if not tg_id or not client_id:
continue
user_exists = await session.execute(select(User).where(User.tg_id == tg_id))
if not user_exists.scalar():
try:
session.add(
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(),
)
)
except SQLAlchemyError:
continue
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
if key_exists.scalar():
skipped += 1
continue
try:
session.add(
Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_time,
key="",
server_id=server_id,
remnawave_link=None,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
)
imported += 1
except SQLAlchemyError:
continue
await session.commit()
return imported, skipped
+23 -19
View File
@@ -1,9 +1,12 @@
from database.db import engine, async_session_maker
from database.models import Base, Admin, User
from sqlalchemy import select
from config import ADMIN_ID
from datetime import datetime
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -11,22 +14,23 @@ async def init_db():
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.tg_id == 0))
if not result.scalar_one_or_none():
session.add(User(
tg_id=0,
username="system",
first_name="System",
is_bot=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
))
session.add(
User(
tg_id=0,
username="system",
first_name="System",
is_bot=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
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()
))
session.add(
Admin(
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
)
)
await session.commit()
+11 -39
View File
@@ -20,13 +20,9 @@ async def store_key(
tariff_id: int = None,
):
try:
exists = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
if exists.scalar_one_or_none():
logger.info(
f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}"
)
logger.info(f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}")
return
new_key = Key(
@@ -42,9 +38,7 @@ async def store_key(
)
session.add(new_key)
await session.commit()
logger.info(
f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}"
)
logger.info(f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
@@ -67,9 +61,7 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
stmt = (
select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
)
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
result = await session.execute(stmt)
row = result.first()
if not row:
@@ -110,26 +102,18 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(
select(func.count()).select_from(Key).where(Key.tg_id == tg_id)
)
result = await session.execute(select(func.count()).select_from(Key).where(Key.tg_id == tg_id))
return result.scalar() or 0
async def delete_key(session: AsyncSession, identifier: int | str):
stmt = delete(Key).where(
Key.tg_id == identifier
if str(identifier).isdigit()
else Key.client_id == identifier
)
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
await session.execute(stmt)
await session.commit()
logger.info(f"Ключ с идентификатором {identifier} удалён")
async def update_key_expiry(
session: AsyncSession, client_id: str, new_expiry_time: int
):
async def update_key_expiry(session: AsyncSession, client_id: str, new_expiry_time: int):
await session.execute(
update(Key)
.where(Key.client_id == client_id)
@@ -145,17 +129,11 @@ async def get_client_id_by_email(session: AsyncSession, email: str):
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.client_id == client_id)
.values(notified=True)
)
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(notified=True))
await session.commit()
async def mark_key_as_frozen(
session: AsyncSession, tg_id: int, client_id: str, time_left: int
):
async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str, time_left: int):
await session.execute(
text(
"""
@@ -170,9 +148,7 @@ async def mark_key_as_frozen(
)
async def mark_key_as_unfrozen(
session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int
):
async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int):
await session.execute(
text(
"""
@@ -188,10 +164,6 @@ async def mark_key_as_unfrozen(
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(tariff_id=tariff_id)
)
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await session.commit()
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
+7 -11
View File
@@ -1,3 +1,6 @@
import secrets
import uuid
from datetime import datetime
from sqlalchemy import (
@@ -12,10 +15,9 @@ from sqlalchemy import (
String,
Text,
)
import secrets
import uuid
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
Base = declarative_base()
@@ -27,9 +29,7 @@ class DictLikeMixin:
return getattr(self, key, default)
def to_dict(self):
return {
column.name: getattr(self, column.name) for column in self.__table__.columns
}
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
class User(DictLikeMixin, Base):
@@ -139,11 +139,7 @@ class Referral(DictLikeMixin, Base):
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(
BigInteger,
ForeignKey("users.tg_id", ondelete="CASCADE"),
primary_key=True
)
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
@@ -219,4 +215,4 @@ class Admin(Base):
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
return secrets.token_urlsafe(32)
+10 -22
View File
@@ -25,17 +25,13 @@ async def add_notification(session: AsyncSession, tg_id: int, notification_type:
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}"
)
logger.info(f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
await session.rollback()
async def delete_notification(
session: AsyncSession, tg_id: int, notification_type: str
):
async def delete_notification(session: AsyncSession, tg_id: int, notification_type: str):
await session.execute(
delete(Notification).where(
Notification.tg_id == tg_id,
@@ -46,9 +42,7 @@ async def delete_notification(
logger.info(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
async def check_notification_time(
session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12
) -> bool:
async def check_notification_time(session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12) -> bool:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
@@ -59,9 +53,7 @@ async def check_notification_time(
return datetime.utcnow() - last_time > timedelta(hours=hours)
async def get_last_notification_time(
session: AsyncSession, tg_id: int, notification_type: str
) -> int | None:
async def get_last_notification_time(session: AsyncSession, tg_id: int, notification_type: str) -> int | None:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
@@ -79,16 +71,14 @@ async def check_notifications_bulk(
tg_ids: list[int] = None,
emails: list[str] = None,
) -> list[dict]:
from sqlalchemy import and_, func, select
from database.models import User, Key, Notification, BlockedUser
from sqlalchemy import select
from database.models import BlockedUser, Notification
try:
now = datetime.utcnow()
subq_last_notification = (
select(
Notification.tg_id,
func.max(Notification.last_notification_time).label("last_notification_time")
)
select(Notification.tg_id, func.max(Notification.last_notification_time).label("last_notification_time"))
.where(Notification.notification_type == notification_type)
.group_by(Notification.tg_id)
.subquery()
@@ -112,7 +102,7 @@ async def check_notifications_bulk(
and_(
User.trial.in_([0, -1]),
~User.tg_id.in_(select(BlockedUser.tg_id)),
~User.tg_id.in_(select(Key.tg_id.distinct()))
~User.tg_id.in_(select(Key.tg_id.distinct())),
)
)
@@ -135,9 +125,7 @@ async def check_notifications_bulk(
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": (
int(last_time.timestamp() * 1000) if last_time else None
)
"last_notification_time": (int(last_time.timestamp() * 1000) if last_time else None),
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
+5 -14
View File
@@ -1,9 +1,9 @@
from datetime import datetime
from pytz import timezone
from sqlalchemy import insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from pytz import timezone
from database.models import Payment
from logger import logger
@@ -12,9 +12,7 @@ from logger import logger
MOSCOW_TZ = timezone("Europe/Moscow")
async def add_payment(
session: AsyncSession, tg_id: int, amount: float, payment_system: str
):
async def add_payment(session: AsyncSession, tg_id: int, amount: float, payment_system: str):
try:
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
stmt = insert(Payment).values(
@@ -26,9 +24,7 @@ async def add_payment(
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}"
)
logger.info(f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
await session.rollback()
@@ -38,15 +34,10 @@ async def add_payment(
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
try:
result = await session.execute(
select(Payment)
.where(Payment.tg_id == tg_id)
.order_by(Payment.created_at.desc())
.limit(limit)
select(Payment).where(Payment.tg_id == tg_id).order_by(Payment.created_at.desc()).limit(limit)
)
payments = result.scalars().all()
logger.info(
f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}"
)
logger.info(f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}")
return [dict(p.__dict__) for p in payments]
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
+15 -52
View File
@@ -13,23 +13,17 @@ async def add_referral(session: AsyncSession, referred_tg_id: int, referrer_tg_i
logger.warning(f"⚠️ Попытка самореферала: {referred_tg_id}")
return
stmt = insert(Referral).values(
referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id
)
stmt = insert(Referral).values(referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Добавлена реферальная связь: {referred_tg_id}{referrer_tg_id}"
)
logger.info(f"✅ Добавлена реферальная связь: {referred_tg_id}{referrer_tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении реферала: {e}")
await session.rollback()
raise
async def get_referral_by_referred_id(
session: AsyncSession, referred_tg_id: int
) -> dict | None:
async def get_referral_by_referred_id(session: AsyncSession, referred_tg_id: int) -> dict | None:
stmt = select(Referral).where(Referral.referred_tg_id == referred_tg_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
@@ -37,11 +31,7 @@ async def get_referral_by_referred_id(
async def get_total_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
stmt = (
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == referrer_tg_id)
)
stmt = select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == referrer_tg_id)
result = await session.execute(stmt)
return result.scalar()
@@ -62,17 +52,11 @@ async def get_active_referrals(session: AsyncSession, referrer_tg_id: int) -> in
async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int):
await session.execute(
update(Referral)
.where(Referral.referred_tg_id == referred_tg_id)
.values(reward_issued=True)
)
await session.execute(update(Referral).where(Referral.referred_tg_id == referred_tg_id).values(reward_issued=True))
await session.commit()
async def get_total_referral_bonus(
session: AsyncSession, referrer_tg_id: int, max_levels: int
) -> float:
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """
WITH RECURSIVE
@@ -168,9 +152,7 @@ async def get_total_referral_bonus(
"""
)
result = await session.execute(
text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}
)
result = await session.execute(text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels})
total_bonus_raw = result.scalar()
total_bonus = round(float(total_bonus_raw or 0), 2)
@@ -178,9 +160,7 @@ async def get_total_referral_bonus(
return total_bonus
async def get_referrals_by_level(
session: AsyncSession, referrer_tg_id: int, max_levels: int
) -> dict:
async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> dict:
query = """
WITH RECURSIVE referral_levels AS (
SELECT referred_tg_id, referrer_tg_id, 1 AS level
@@ -200,9 +180,7 @@ async def get_referrals_by_level(
GROUP BY level
ORDER BY level
"""
result = await session.execute(
text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}
)
result = await session.execute(text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels})
return {
row["level"]: {
"total": row["level_count"],
@@ -214,19 +192,13 @@ async def get_referrals_by_level(
async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
try:
logger.info(
f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}"
)
logger.info(f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}")
total_referrals = await get_total_referrals(session, referrer_tg_id)
active_referrals = await get_active_referrals(session, referrer_tg_id)
max_levels = len(REFERRAL_BONUS_PERCENTAGES)
referrals_by_level = await get_referrals_by_level(
session, referrer_tg_id, max_levels
)
total_referral_bonus = await get_total_referral_bonus(
session, referrer_tg_id, max_levels
)
referrals_by_level = await get_referrals_by_level(session, referrer_tg_id, max_levels)
total_referral_bonus = await get_total_referral_bonus(session, referrer_tg_id, max_levels)
return {
"total_referrals": total_referrals,
@@ -236,18 +208,12 @@ async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
}
except Exception as e:
logger.error(
f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}"
)
logger.error(f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}")
raise
async def get_user_referral_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == tg_id)
)
result = await session.execute(select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == tg_id))
return result.scalar_one() or 0
@@ -272,7 +238,4 @@ async def get_top_referrals(session: AsyncSession, limit: int = 5):
.limit(limit)
)
result = await session.execute(query)
return [
{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count}
for row in result.all()
]
return [{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count} for row in result.all()]
+32 -56
View File
@@ -1,8 +1,8 @@
from sqlalchemy import delete, insert, select, update, func
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server, Key
from database.models import Key, Server
from logger import logger
@@ -54,19 +54,17 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
grouped.setdefault(cluster, []).append(
{
"server_name": s.server_name,
"api_url": s.api_url,
"subscription_url": s.subscription_url,
"inbound_id": s.inbound_id,
"panel_type": s.panel_type,
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"cluster_name": cluster,
}
)
grouped.setdefault(cluster, []).append({
"server_name": s.server_name,
"api_url": s.api_url,
"subscription_url": s.subscription_url,
"inbound_id": s.inbound_id,
"panel_type": s.panel_type,
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"cluster_name": cluster,
})
return grouped
except SQLAlchemyError as e:
@@ -80,9 +78,7 @@ async def get_clusters(session: AsyncSession) -> list[str]:
return [r[0] for r in result.all()]
async def check_unique_server_name(
session: AsyncSession, server_name: str, cluster_name: str | None = None
) -> bool:
async def check_unique_server_name(session: AsyncSession, server_name: str, cluster_name: str | None = None) -> bool:
stmt = select(Server).where(Server.server_name == server_name)
if cluster_name:
stmt = stmt.where(Server.cluster_name == cluster_name)
@@ -90,13 +86,9 @@ async def check_unique_server_name(
return result.scalar_one_or_none() is None
async def check_server_name_by_cluster(
session: AsyncSession, server_name: str
) -> dict | None:
async def check_server_name_by_cluster(session: AsyncSession, server_name: str) -> dict | None:
try:
result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name)
)
result = await session.execute(select(Server.cluster_name).where(Server.server_name == server_name))
row = result.first()
return {"cluster_name": row[0]} if row else None
except SQLAlchemyError as e:
@@ -104,14 +96,10 @@ async def check_server_name_by_cluster(
return None
async def get_cluster_name_by_server(
session: AsyncSession, server_id_or_name: str
) -> str | None:
async def get_cluster_name_by_server(session: AsyncSession, server_id_or_name: str) -> str | None:
stmt = (
select(Server.cluster_name)
.where(
(Server.id == server_id_or_name) | (Server.server_name == server_id_or_name)
)
.where((Server.id == server_id_or_name) | (Server.server_name == server_id_or_name))
.limit(1)
)
@@ -125,7 +113,7 @@ async def get_server_by_name(session: AsyncSession, server_name: str) -> dict |
stmt = select(Server).where(Server.server_name == server_name)
result = await session.execute(stmt)
server = result.scalar_one_or_none()
if server:
return {
"id": server.id,
@@ -145,9 +133,7 @@ async def get_server_by_name(session: AsyncSession, server_name: str) -> dict |
return None
async def update_server_field(
session: AsyncSession, server_name: str, field: str, value: any
) -> bool:
async def update_server_field(session: AsyncSession, server_name: str, field: str, value: any) -> bool:
try:
stmt = update(Server).where(Server.server_name == server_name).values(**{field: value})
await session.execute(stmt)
@@ -160,11 +146,10 @@ async def update_server_field(
return False
async def update_server_name_with_keys(
session: AsyncSession, old_name: str, new_name: str
) -> bool:
async def update_server_name_with_keys(session: AsyncSession, old_name: str, new_name: str) -> bool:
try:
from sqlalchemy import update
from database.models import Key
if not await check_unique_server_name(session, new_name):
@@ -176,7 +161,7 @@ async def update_server_name_with_keys(
stmt_keys = update(Key).where(Key.server_id == old_name).values(server_id=new_name)
await session.execute(stmt_keys)
await session.commit()
logger.info(f"✅ Сервер переименован с {old_name} на {new_name}")
return True
@@ -196,16 +181,12 @@ async def get_available_clusters(session: AsyncSession) -> list[str]:
return []
async def update_server_cluster(
session: AsyncSession,
server_name: str,
new_cluster: str
) -> bool:
async def update_server_cluster(session: AsyncSession, server_name: str, new_cluster: str) -> bool:
try:
server_data = await get_server_by_name(session, server_name)
if not server_data:
return False
old_cluster = server_data["cluster_name"]
stmt_remaining = select(func.count()).where(
@@ -215,26 +196,21 @@ async def update_server_cluster(
remaining_servers = result.scalar_one()
if remaining_servers == 0:
stmt_update_keys = update(Key).where(
Key.server_id == old_cluster
).values(server_id=new_cluster)
stmt_update_keys = update(Key).where(Key.server_id == old_cluster).values(server_id=new_cluster)
await session.execute(stmt_update_keys)
stmt_new_cluster = select(Server.tariff_group).where(
Server.cluster_name == new_cluster
).limit(1)
stmt_new_cluster = select(Server.tariff_group).where(Server.cluster_name == new_cluster).limit(1)
result = await session.execute(stmt_new_cluster)
new_tariff_group = result.scalar_one_or_none()
stmt_update = update(Server).where(
Server.server_name == server_name
).values(
cluster_name=new_cluster,
tariff_group=new_tariff_group
stmt_update = (
update(Server)
.where(Server.server_name == server_name)
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
)
await session.execute(stmt_update)
await session.commit()
logger.info(f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы")
return True
except SQLAlchemyError as e:
+20 -56
View File
@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import and_, func, not_, select, exists
from sqlalchemy import and_, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Referral, Tariff, User
@@ -11,24 +11,16 @@ async def count_total_users(session: AsyncSession) -> int:
async def count_users_updated_today(session: AsyncSession, today: date) -> int:
return await session.scalar(
select(func.count()).select_from(User).where(User.updated_at >= today)
)
return await session.scalar(select(func.count()).select_from(User).where(User.updated_at >= today))
async def count_users_registered_since(session: AsyncSession, since: date) -> int:
return await session.scalar(
select(func.count()).select_from(User).where(User.created_at >= since)
)
return await session.scalar(select(func.count()).select_from(User).where(User.created_at >= since))
async def count_users_registered_between(
session: AsyncSession, start: date, end: date
) -> int:
async def count_users_registered_between(session: AsyncSession, start: date, end: date) -> int:
return await session.scalar(
select(func.count())
.select_from(User)
.where(User.created_at >= start, User.created_at < end)
select(func.count()).select_from(User).where(User.created_at >= start, User.created_at < end)
)
@@ -38,78 +30,55 @@ async def count_total_keys(session: AsyncSession) -> int:
async def count_active_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
return await session.scalar(
select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms)
)
return await session.scalar(select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms))
async def count_trial_keys(session: AsyncSession) -> int:
subquery_success_payments = (
select(Payment.tg_id)
.where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success"))
.exists()
select(Payment.tg_id).where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success")).exists()
)
return await session.scalar(
select(func.count()).select_from(Key).where(not_(subquery_success_payments))
)
return await session.scalar(select(func.count()).select_from(Key).where(not_(subquery_success_payments)))
async def get_tariff_distribution(
session: AsyncSession, include_unbound: bool = False
) -> tuple[list[tuple[int, int]], list[dict]]:
result = await session.execute(
select(Key.tariff_id, func.count(Key.client_id))
.where(Key.tariff_id.isnot(None))
.group_by(Key.tariff_id)
select(Key.tariff_id, func.count(Key.client_id)).where(Key.tariff_id.isnot(None)).group_by(Key.tariff_id)
)
tariff_counts = result.all()
if not include_unbound:
return tariff_counts
result = await session.execute(
select(Key.expiry_time)
.where(Key.tariff_id.is_(None))
)
result = await session.execute(select(Key.expiry_time).where(Key.tariff_id.is_(None)))
no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
return tariff_counts, no_tariff_keys
async def get_tariff_names(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, str]:
async def get_tariff_names(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids))
)
result = await session.execute(select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_groups(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, str]:
async def get_tariff_groups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids))
)
result = await session.execute(select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_durations(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, int]:
async def get_tariff_durations(session: AsyncSession, tariff_ids: list[int]) -> dict[int, int]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids))
)
result = await session.execute(select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
@@ -120,10 +89,7 @@ async def count_total_referrals(session: AsyncSession) -> int:
async def sum_payments_since(session: AsyncSession, since: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= since,
Payment.payment_system.notin_(["referral", "coupon", "cashback"])
)
and_(Payment.created_at >= since, Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
)
)
return round(float(result), 2)
@@ -135,7 +101,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
and_(
Payment.created_at >= start,
Payment.created_at < end,
Payment.payment_system.notin_(["referral", "coupon", "cashback"])
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
)
)
)
@@ -153,9 +119,7 @@ async def sum_total_payments(session: AsyncSession) -> float:
async def count_hot_leads(session: AsyncSession) -> int:
subquery_active_keys = (
select(Key.tg_id)
.where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000))
.distinct()
select(Key.tg_id).where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000)).distinct()
)
stmt = (
@@ -168,4 +132,4 @@ async def count_hot_leads(session: AsyncSession) -> int:
)
result = await session.execute(select(func.count()).select_from(stmt.subquery()))
return result.scalar()
return result.scalar()
+15 -31
View File
@@ -1,6 +1,7 @@
from datetime import datetime
import hashlib
from datetime import datetime
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,7 +15,7 @@ def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
return ""
unique_key = f"{subgroup_title}:{group_code}"
hash_object = hashlib.md5(unique_key.encode('utf-8'))
hash_object = hashlib.md5(unique_key.encode("utf-8"))
return hash_object.hexdigest()[:8]
@@ -25,24 +26,20 @@ async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group
.distinct()
)
subgroups = [row[0] for row in result.fetchall()]
for subgroup_title in subgroups:
if create_subgroup_hash(subgroup_title, group_code) == subgroup_hash:
return subgroup_title
return None
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None
):
async def get_tariffs(session: AsyncSession, tariff_id: int = None, group_code: str = None):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
)
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
else:
result = await session.execute(select(Tariff))
@@ -65,34 +62,26 @@ async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
try:
server_row = await session.execute(
select(Server.tariff_group)
.where(Server.cluster_name == cluster_name)
.limit(1)
select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1)
)
row = server_row.first()
if not row:
server_row = await session.execute(
select(Server.tariff_group)
.where(Server.server_name == cluster_name)
.limit(1)
select(Server.tariff_group).where(Server.server_name == cluster_name).limit(1)
)
row = server_row.first()
if not row or not row[0]:
return []
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.id)
select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id)
)
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(
f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}"
)
logger.error(f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}")
return []
@@ -116,9 +105,7 @@ async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
return False
try:
updates["updated_at"] = datetime.utcnow()
await session.execute(
update(Tariff).where(Tariff.id == tariff_id).values(**updates)
)
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(**updates))
await session.commit()
return True
except SQLAlchemyError as e:
@@ -140,10 +127,7 @@ async def delete_tariff(session: AsyncSession, tariff_id: int):
async def check_tariff_exists(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(
select(Tariff)
.where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
)
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}")
+1 -3
View File
@@ -9,9 +9,7 @@ from database.models import TemporaryData
from logger import logger
async def create_temporary_data(
session: AsyncSession, tg_id: int, state: str, data: dict
):
async def create_temporary_data(session: AsyncSession, tg_id: int, state: str, data: dict):
try:
stmt = (
insert(TemporaryData)
+14 -27
View File
@@ -1,4 +1,4 @@
from sqlalchemy import func, insert, select, not_
from sqlalchemy import func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,9 +6,7 @@ from database.models import Payment, TrackingSource, User
from logger import logger
async def create_tracking_source(
session: AsyncSession, name: str, code: str, type_: str, created_by: int
):
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
try:
stmt = insert(TrackingSource).values(
name=name,
@@ -42,9 +40,7 @@ async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == TrackingSource.code) & (Payment.status == "success")
)
.where((User.source_code == TrackingSource.code) & (Payment.status == "success"))
.correlate(TrackingSource)
.scalar_subquery()
)
@@ -74,9 +70,7 @@ async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
source_result = await session.execute(
select(TrackingSource.created_at).where(TrackingSource.code == code)
)
source_result = await session.execute(select(TrackingSource.created_at).where(TrackingSource.code == code))
created_at_row = source_result.first()
if not created_at_row:
return None
@@ -85,20 +79,13 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(
(User.source_code == code) &
(User.created_at >= created_at)
)
.where((User.source_code == code) & (User.created_at >= created_at))
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(
(User.source_code == code) &
(User.trial == 1) &
(User.created_at >= created_at)
)
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.scalar_subquery()
)
@@ -106,10 +93,10 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code) &
(Payment.status == "success") &
not_(Payment.payment_system.in_(["coupon", "referral", "cashback"])) &
(Payment.created_at >= created_at)
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
@@ -118,10 +105,10 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
select(func.coalesce(func.sum(Payment.amount), 0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code) &
(Payment.status == "success") &
not_(Payment.payment_system.in_(["coupon", "referral", "cashback"])) &
(Payment.created_at >= created_at)
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
+9 -30
View File
@@ -10,12 +10,12 @@ from database.models import (
BlockedUser,
CouponUsage,
Gift,
GiftUsage,
Notification,
Payment,
Referral,
TemporaryData,
User,
GiftUsage
)
from logger import logger
@@ -47,9 +47,7 @@ async def add_user(
await session.execute(stmt)
await session.commit()
logger.info(
f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})"
)
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
await session.rollback()
@@ -61,13 +59,9 @@ async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> No
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
current = result.scalar_one_or_none() or 0
new_balance = current + amount
await session.execute(
update(User).where(User.tg_id == tg_id).values(balance=new_balance)
)
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=new_balance))
await session.commit()
logger.info(
f"[DB] Баланс пользователя {tg_id} обновлён: {current}{new_balance}"
)
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {current}{new_balance}")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
await session.rollback()
@@ -87,9 +81,7 @@ async def get_balance(session: AsyncSession, tg_id: int) -> float:
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
try:
await session.execute(
update(User).where(User.tg_id == tg_id).values(balance=balance)
)
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=balance))
await session.commit()
except SQLAlchemyError as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
@@ -98,9 +90,7 @@ async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) ->
async def update_trial(session: AsyncSession, tg_id: int, status: int):
try:
await session.execute(
update(User).where(User.tg_id == tg_id).values(trial=status)
)
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=status))
await session.commit()
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
except SQLAlchemyError as e:
@@ -181,29 +171,18 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
try:
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
result = await session.execute(
select(Gift.gift_id).where(Gift.sender_tg_id == tg_id)
)
result = await session.execute(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id))
gift_ids = [row[0] for row in result.all()]
if gift_ids:
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
await session.execute(
update(Gift)
.where(Gift.recipient_tg_id == tg_id)
.values(recipient_tg_id=None)
)
await session.execute(update(Gift).where(Gift.recipient_tg_id == tg_id).values(recipient_tg_id=None))
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
await session.execute(
delete(Referral).where(
or_(
Referral.referrer_tg_id == tg_id,
Referral.referred_tg_id == tg_id
)
)
delete(Referral).where(or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id))
)
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
await delete_key(session, tg_id)
+4 -5
View File
@@ -1,17 +1,16 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from database.models import Admin
from database.db import async_session_maker
from database.models import Admin
class IsAdminFilter(BaseFilter):
async def __call__(self, event: Message | CallbackQuery) -> bool:
try:
async with async_session_maker() as session:
result = await session.execute(
select(Admin).where(Admin.tg_id == event.from_user.id)
)
result = await session.execute(select(Admin).where(Admin.tg_id == event.from_user.id))
admin = result.scalar_one_or_none()
return admin is not None
except Exception:
+1
View File
@@ -15,6 +15,7 @@ from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
router = Router(name="handlers_main_router")
router.include_routers(
+3 -2
View File
@@ -7,6 +7,7 @@ from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .gifts import router as gifts_router
from .management import router as management_router
from .panel import router as panel_router
from .restart import router as restart_router
@@ -15,7 +16,7 @@ from .servers import router as servers_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
from .gifts import router as gifts_router
router = Router(name="admins_main_router")
@@ -33,5 +34,5 @@ router.include_routers(
bans_router,
ads_router,
tariffs_router,
gifts_router
gifts_router,
)
+18 -40
View File
@@ -1,10 +1,11 @@
import re
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 import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
import re
from config import USERNAME_BOT
from database import create_tracking_source, get_tracking_source_stats
@@ -22,6 +23,7 @@ from .keyboard import (
build_cancel_input_kb,
)
router = Router()
@@ -32,9 +34,7 @@ class AdminAdsState(StatesGroup):
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
)
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
@@ -51,28 +51,24 @@ async def handle_ads_name_input(message: Message, state: FSMContext):
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.\n\n"
f"💡 <b>Правила:</b> только латинские буквы и цифры",
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.\n\n💡 <b>Правила:</b> только латинские буквы и цифры",
reply_markup=build_cancel_input_kb(),
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(
message: Message, state: FSMContext, session: AsyncSession
):
async def handle_ads_code_input(message: Message, state: FSMContext, session: AsyncSession):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
if not re.match(r'^[a-zA-Z0-9]+$', code):
if not re.match(r"^[a-zA-Z0-9]+$", code):
await message.answer(
"❌ Код может содержать только латинские буквы и цифры\n"
"Введите код заново:",
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
reply_markup=build_cancel_input_kb(),
)
return
code_with_prefix = f"utm_{code}"
try:
@@ -103,9 +99,7 @@ async def handle_ads_code_input(
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession, callback_data: AdminAdsCallback):
try:
result = await session.execute(
select(TrackingSource).order_by(TrackingSource.created_at.desc())
)
result = await session.execute(select(TrackingSource).order_by(TrackingSource.created_at.desc()))
ads = result.scalars().all()
items_per_page = 6
if callback_data.code and callback_data.code.isdigit():
@@ -119,9 +113,7 @@ async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession,
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
await callback_query.message.edit_text(
"❌ Произошла ошибка при получении списка."
)
await callback_query.message.edit_text("❌ Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
@@ -134,25 +126,17 @@ async def handle_ads_view(
try:
stats = await get_tracking_source_stats(session, code)
if not stats:
await callback_query.message.edit_text(
"❌ Источник не найден или не содержит данных."
)
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(
text=msg, reply_markup=build_ads_stats_kb(code)
)
await callback_query.message.edit_text(text=msg, reply_markup=build_ads_stats_kb(code))
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(
AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter()
)
async def handle_ads_delete_confirm(
callback_query: CallbackQuery, callback_data: AdminAdsCallback
):
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
@@ -168,9 +152,7 @@ async def handle_ads_delete(
):
code = callback_data.code
try:
await session.execute(
update(User).where(User.source_code == code).values(source_code=None)
)
await session.execute(update(User).where(User.source_code == code).values(source_code=None))
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
await session.commit()
await callback_query.message.edit_text(
@@ -182,14 +164,10 @@ async def handle_ads_delete(
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
@router.callback_query(
AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter()
)
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
)
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
def format_ads_stats(stats: dict, username_bot: str) -> str:
+5 -15
View File
@@ -12,20 +12,14 @@ class AdminAdsCallback(CallbackData, prefix="admin_ads"):
def build_ads_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=" Новая ссылка", callback_data=AdminAdsCallback(action="create").pack()
)
builder.button(
text="📊 Список", callback_data=AdminAdsCallback(action="list").pack()
)
builder.button(text=" Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_ads_list_kb(
ads: list, current_page: int, total_pages: int
) -> InlineKeyboardMarkup:
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
items_per_page = 6
@@ -50,18 +44,14 @@ def build_ads_list_kb(
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminAdsCallback(
action="list", code=f"{current_page - 1}"
).pack(),
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminAdsCallback(
action="list", code=f"{current_page + 1}"
).pack(),
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
)
)
if pagination_buttons:
@@ -6,6 +6,7 @@ from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
+12 -33
View File
@@ -14,6 +14,7 @@ from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
router = Router()
@@ -28,9 +29,7 @@ async def handle_bans(callback_query: CallbackQuery):
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter())
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("management")
try:
@@ -45,9 +44,7 @@ async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSessio
writer.writerow([user.tg_id])
csv_output.seek(0)
document = BufferedInputFile(
file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
await callback_query.message.answer_document(
document=document,
@@ -60,12 +57,8 @@ async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSessio
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter()
)
async def handle_bans_delete_banned(
callback_query: CallbackQuery, session: AsyncSession
):
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter())
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("bans")
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
@@ -99,17 +92,11 @@ async def handle_bans_delete_banned(
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter()
)
async def handle_manual_bans_export(
callback_query: CallbackQuery, session: AsyncSession
):
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
build_admin_back_kb("bans")
try:
result = await session.execute(
text("SELECT tg_id, banned_at, reason, until FROM manual_bans")
)
result = await session.execute(text("SELECT tg_id, banned_at, reason, until FROM manual_bans"))
rows = result.all()
csv_output = io.StringIO()
@@ -120,9 +107,7 @@ async def handle_manual_bans_export(
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
csv_output.seek(0)
document = BufferedInputFile(
file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv"
)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
await callback_query.message.answer_document(
document=document,
@@ -135,12 +120,8 @@ async def handle_manual_bans_export(
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter()
)
async def handle_delete_manual_banned(
callback_query: CallbackQuery, session: AsyncSession
):
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter())
async def handle_delete_manual_banned(callback_query: CallbackQuery, session: AsyncSession):
try:
await session.execute(delete(ManualBan))
await session.commit()
@@ -151,6 +132,4 @@ async def handle_delete_manual_banned(
logger.info("[BANS] Очищены записи из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
await callback_query.message.edit_text(
"❌ Ошибка при удалении вручную забаненных пользователей."
)
await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.")
+1 -3
View File
@@ -23,9 +23,7 @@ def build_bans_kb():
text="🗑️ Очистить вручную забаненных",
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
)
builder.button(
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack())
builder.adjust(1)
return builder.as_markup()
+63 -183
View File
@@ -1,4 +1,5 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
@@ -42,6 +43,7 @@ from .keyboard import (
build_tariff_group_selection_kb,
)
router = Router()
@@ -89,9 +91,7 @@ async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
"<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 callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
@@ -249,9 +249,7 @@ async def handle_panel_type_selection(
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)
)
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
@@ -275,9 +273,7 @@ async def handle_panel_type_selection(
await state.clear()
@router.callback_query(
AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
@@ -286,16 +282,12 @@ async def handle_clusters_manage(
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)
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)
)
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(
@@ -305,9 +297,7 @@ async def handle_clusters_manage(
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))
)
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
)
subscription_count = result.scalar() or 0
@@ -336,9 +326,7 @@ async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession)
)
@router.callback_query(
AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
async def handle_cluster_availability(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
@@ -349,9 +337,7 @@ async def handle_cluster_availability(
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(
text=f"Кластер '{cluster_name}' не содержит серверов."
)
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
await callback_query.message.edit_text(
@@ -397,9 +383,7 @@ async def handle_cluster_availability(
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
username=REMNAWAVE_LOGIN, password=REMNAWAVE_PASSWORD, inbound_id=server_inbound_id
)
if nodes_data.get("error"):
@@ -412,34 +396,28 @@ async def handle_cluster_availability(
if len(nodes_info) > 1:
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
for node_info in nodes_info:
country_code = node_info.get('country_code', 'Unknown')
node_name = node_info.get('name', 'Unknown')
online_users = node_info.get('online_users', 0)
country_code = node_info.get("country_code", "Unknown")
node_name = node_info.get("name", "Unknown")
online_users = node_info.get("online_users", 0)
if country_code != 'Unknown' and len(country_code) == 2:
flag = ''.join(chr(ord(c) + 127397) for c in country_code.upper())
if country_code != "Unknown" and len(country_code) == 2:
flag = "".join(chr(ord(c) + 127397) for c in country_code.upper())
else:
flag = country_code
result_text += f"{flag} ({node_name}): {online_users} онлайн\n"
else:
result_text += (
f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
)
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_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_admin_back_kb("clusters")
)
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
@router.callback_query(
AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
@@ -490,9 +468,7 @@ async def handle_sync(
)
@router.callback_query(
AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
async def handle_sync_server(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
@@ -552,9 +528,7 @@ async def handle_sync_server(
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(
f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}"
)
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
@@ -568,9 +542,7 @@ async def handle_sync_server(
)
@router.callback_query(
AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
async def handle_sync_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
@@ -610,9 +582,7 @@ async def handle_sync_cluster(
try:
if only_remnawave:
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000)
.replace(tzinfo=timezone.utc)
.isoformat()
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
)
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
@@ -634,11 +604,7 @@ async def handle_sync_cluster(
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']} — обновим без лимитов"
)
inbound_ids = [
s["inbound_id"]
for s in cluster_servers
if s.get("inbound_id")
]
inbound_ids = [s["inbound_id"] for s in cluster_servers if s.get("inbound_id")]
success = await remna.update_user(
uuid=key["client_id"],
@@ -651,17 +617,12 @@ async def handle_sync_cluster(
)
if not success:
logger.warning(f"[Sync] ошибка обновления, пробуем пересоздать")
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
await delete_key_from_cluster(
cluster_name, key["email"], key["client_id"], session
)
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"]
)
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
await create_key_on_cluster(
@@ -674,19 +635,14 @@ async def handle_sync_cluster(
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 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"]
)
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
await create_key_on_cluster(
@@ -699,13 +655,11 @@ async def handle_sync_cluster(
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}"
)
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
@@ -721,9 +675,7 @@ async def handle_sync_cluster(
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_add_server(
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext
):
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)
@@ -742,9 +694,7 @@ async def handle_add_server(
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.callback_query(
AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
@@ -787,9 +737,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
)
row = result.first()
if not row or not row[0]:
await message.answer(
"❌ Не удалось определить тарифную группу для этого кластера или сервера."
)
await message.answer("❌ Не удалось определить тарифную группу для этого кластера или сервера.")
await state.clear()
return
@@ -813,16 +761,12 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
total_gb = tariff.traffic_limit or 0
server_stmt = select(Server.server_name).where(
Server.cluster_name == 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))
)
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
keys = result.scalars().all()
if not keys:
@@ -843,9 +787,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
)
await update_key_expiry(session, key.client_id, new_expiry)
logger.info(
f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}"
)
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
@@ -860,9 +802,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
await state.clear()
@router.callback_query(
AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter()
)
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
@@ -886,9 +826,7 @@ async def handle_rename_cluster(
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
async def handle_new_cluster_name_input(
message: Message, state: FSMContext, session: AsyncSession
):
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
@@ -909,9 +847,7 @@ async def handle_new_cluster_name_input(
try:
result = await session.execute(
select(Server.cluster_name)
.where(Server.cluster_name == new_cluster_name)
.limit(1)
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
)
existing_cluster = result.scalar()
@@ -923,23 +859,17 @@ async def handle_new_cluster_name_input(
return
keys_count_result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.server_id == old_cluster_name)
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)
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)
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
)
await session.commit()
@@ -950,9 +880,7 @@ async def handle_new_cluster_name_input(
)
except Exception as e:
await session.rollback()
logger.error(
f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}"
)
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
reply_markup=build_admin_back_kb("clusters"),
@@ -961,9 +889,7 @@ async def handle_new_cluster_name_input(
await state.clear()
@router.callback_query(
AdminServerCallback.filter(F.action == "rename"), IsAdminFilter()
)
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_server(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
@@ -1006,9 +932,7 @@ async def handle_rename_server(
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
async def handle_new_server_name_input(
message: Message, state: FSMContext, session: AsyncSession
):
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
@@ -1045,11 +969,7 @@ async def handle_new_server_name_input(
)
return
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.server_id == old_server_name)
)
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
keys_count = result.scalar()
await session.execute(
@@ -1062,11 +982,7 @@ async def handle_new_server_name_input(
)
if keys_count > 0:
await session.execute(
update(Key)
.where(Key.server_id == old_server_name)
.values(server_id=new_server_name)
)
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.commit()
@@ -1076,9 +992,7 @@ async def handle_new_server_name_input(
)
except Exception as e:
await session.rollback()
logger.error(
f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}"
)
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
reply_markup=build_admin_back_kb("clusters"),
@@ -1088,9 +1002,7 @@ async def handle_new_server_name_input(
@router.callback_query(F.data.startswith("transfer_to_server|"))
async def handle_server_transfer(
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
):
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_server_name = data[1]
@@ -1099,11 +1011,7 @@ async def handle_server_transfer(
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(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.execute(
delete(Server).where(
@@ -1134,9 +1042,7 @@ async def handle_server_transfer(
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
async def handle_cluster_transfer(
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
):
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_cluster_name = data[1]
@@ -1146,16 +1052,8 @@ async def handle_cluster_transfer(
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(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(
@@ -1185,17 +1083,11 @@ async def handle_cluster_transfer(
await state.clear()
@router.callback_query(
AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter()
)
async def show_tariff_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session
):
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
async def show_tariff_group_selection(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
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)
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]
@@ -1210,19 +1102,13 @@ async def show_tariff_group_selection(
)
@router.callback_query(
AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter()
)
async def apply_tariff_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session
):
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
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)
)
result = await session.execute(select(Tariff.group_code).where(Tariff.id == group_id))
row = result.mappings().first()
if not row:
@@ -1231,11 +1117,7 @@ async def apply_tariff_group(
group_code = row["group_code"]
await session.execute(
update(Server)
.where(Server.cluster_name == cluster_name)
.values(tariff_group=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(
@@ -1245,6 +1127,4 @@ async def apply_tariff_group(
except Exception as e:
logger.error(f"Ошибка при применении тарифной группы: {e}")
await callback.message.edit_text(
"❌ Произошла ошибка при установке тарифной группы."
)
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
+17 -53
View File
@@ -24,9 +24,7 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
row_buttons.append(
InlineKeyboardButton(
text=label,
callback_data=AdminClusterCallback(
action="manage", data=name
).pack(),
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
)
)
builder.row(*row_buttons)
@@ -42,18 +40,14 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_manage_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=AdminServerCallback(
action="manage", data=server["server_name"]
).pack(),
callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
)
)
@@ -67,9 +61,7 @@ def build_manage_cluster_kb(
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
@@ -88,9 +80,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
callback_data=AdminClusterCallback(
action="availability", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
)
)
builder.row(
@@ -102,65 +92,47 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап",
callback_data=AdminClusterCallback(
action="backup", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить время",
callback_data=AdminClusterCallback(
action="add_time", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Сменить название",
callback_data=AdminClusterCallback(
action="rename", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(
action="set_tariff", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
def build_sync_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🔄 Синхронизировать {server['server_name']}",
callback_data=AdminClusterCallback(
action="sync-server", data=server["server_name"]
).pack(),
callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Синхронизировать кластер",
callback_data=AdminClusterCallback(
action="sync-cluster", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
)
)
@@ -171,9 +143,7 @@ def build_sync_cluster_kb(
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack()
)
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(
text="🌀 Remnawave",
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
@@ -182,23 +152,17 @@ def build_panel_type_kb() -> InlineKeyboardMarkup:
return builder.as_markup()
def build_tariff_group_selection_kb(
cluster_name: str, groups: list[tuple[int, str]]
) -> InlineKeyboardMarkup:
def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(
action="apply_tariff_group", data=f"{cluster_name}|{group_id}"
).pack(),
callback_data=AdminClusterCallback(action="apply_tariff_group", data=f"{cluster_name}|{group_id}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
+13 -36
View File
@@ -28,6 +28,7 @@ from .keyboard import (
format_coupons_list,
)
router = Router()
@@ -42,9 +43,7 @@ class AdminCouponsState(StatesGroup):
IsAdminFilter(),
)
async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()
)
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@router.callback_query(
@@ -64,9 +63,7 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(
callback_query: CallbackQuery, state: FSMContext
):
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
@@ -80,9 +77,7 @@ async def handle_balance_coupon_selection(
@router.callback_query(F.data == "coupon_type_days")
async def handle_days_coupon_selection(
callback_query: CallbackQuery, state: FSMContext
):
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
@@ -96,9 +91,7 @@ async def handle_days_coupon_selection(
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(
message: Message, state: FSMContext, session: Any
):
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
@@ -147,9 +140,7 @@ async def handle_balance_coupon_input(
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer(
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
)
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
@@ -202,9 +193,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer(
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
)
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.callback_query(
@@ -218,14 +207,10 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
await update_coupons_list(callback_query.message, session, page)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при получении списка купонов."
)
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter()
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
async def handle_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
@@ -235,15 +220,11 @@ async def handle_coupon_delete(
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить",
callback_data=AdminCouponDeleteCallback(
coupon_code=coupon_code, confirm=True
).pack(),
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack(),
)
kb.button(
text="❌ Нет, отменить",
callback_data=AdminCouponDeleteCallback(
coupon_code=coupon_code, confirm=False
).pack(),
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack(),
)
kb.adjust(1)
@@ -253,9 +234,7 @@ async def handle_coupon_delete(
)
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter()
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
async def confirm_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
@@ -340,9 +319,7 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
id=coupon_code,
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(),
)
+3 -9
View File
@@ -35,9 +35,7 @@ def build_coupons_kb() -> InlineKeyboardMarkup:
return builder.as_markup()
def build_coupons_list_kb(
coupons: list, current_page: int, total_pages: int
) -> InlineKeyboardMarkup:
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for coupon in coupons:
@@ -52,18 +50,14 @@ def build_coupons_list_kb(
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page - 1
).pack(),
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page + 1
).pack(),
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
)
)
if pagination_buttons:
+1 -1
View File
@@ -1,3 +1,3 @@
__all__ = ("router",)
from .gifts_handler import router
from .gifts_handler import router
+30 -61
View File
@@ -1,54 +1,49 @@
from aiogram import F, Router, types, Bot
from aiogram.types import CallbackQuery
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from aiogram.types import CallbackQuery
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.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
from database.models import Tariff, Gift, GiftUsage
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.utils import format_days, format_months, edit_or_send_message
from logger import logger
router = Router()
class GiftCreationState(StatesGroup):
waiting_for_gift_limit = State()
waiting_for_limit_input_or_unlimited = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"))
async def admin_gift_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:",
reply_markup=build_admin_gifts_kb()
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:", reply_markup=build_admin_gifts_kb()
)
@router.callback_query(F.data == "admin_gift_create")
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
stmt = (
select(Tariff)
.where(Tariff.group_code == "gifts", Tariff.is_active == True)
.order_by(Tariff.duration_days)
)
stmt = select(Tariff).where(Tariff.group_code == "gifts", Tariff.is_active is True).order_by(Tariff.duration_days)
result = await session.execute(stmt)
tariffs = result.scalars().all()
if not tariffs:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text(
"❌ Нет активных тарифов в группе 'gifts'.",
reply_markup=builder.as_markup()
)
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
from collections import defaultdict
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.subgroup_title].append(t)
@@ -61,10 +56,7 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
else:
duration_text = format_days(t.duration_days)
builder.button(
text=f"{t.name} {duration_text}",
callback_data=f"admin_gift_select|{t.id}"
)
builder.button(text=f"{t.name} {duration_text}", callback_data=f"admin_gift_select|{t.id}")
for subgroup in sorted(k for k in grouped_tariffs if k):
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
@@ -75,16 +67,9 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
)
)
builder.row(
types.InlineKeyboardButton(
text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()
)
)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text(
"🎁 Выберите тариф для подарка:",
reply_markup=builder.as_markup()
)
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("admin_gift_subgroup|"))
@@ -98,9 +83,7 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
return
stmt = (
select(Tariff)
.where(Tariff.group_code == "gifts", Tariff.is_active == True)
.order_by(Tariff.duration_days)
select(Tariff).where(Tariff.group_code == "gifts", Tariff.is_active is True).order_by(Tariff.duration_days)
)
result = await session.execute(stmt)
tariffs = result.scalars().all()
@@ -124,9 +107,7 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
)
)
builder.row(
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create")
)
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
await edit_or_send_message(
target_message=callback.message,
@@ -148,22 +129,24 @@ async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
kb = InlineKeyboardBuilder()
kb.button(text="🔙 Назад", callback_data="admin_gift_create")
await callback.message.edit_text(
"🔢 Введите максимальное количество активаций подарка:",
reply_markup=kb.as_markup()
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
)
@router.callback_query(F.data == "gift_limit_unlimited")
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
data = await state.get_data()
session: AsyncSession = callback.bot["session"]
await state.clear()
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
try:
max_usages = int(message.text.strip())
if max_usages <= 0:
@@ -198,14 +181,12 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
gifts = result.scalars().all()
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text(
"❌ Подарки не найдены.",
reply_markup=builder.as_markup()
)
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
@@ -213,10 +194,7 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
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()
)
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):
@@ -231,10 +209,7 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text(
"❌ Подарки не найдены.",
reply_markup=builder.as_markup()
)
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
@@ -242,10 +217,7 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
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=builder.as_markup())
@router.callback_query(F.data.startswith("gift_view|"))
@@ -298,7 +270,4 @@ async def delete_gift(callback: CallbackQuery, session: AsyncSession):
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад к списку", callback_data="admin_gifts_all")
await callback.message.edit_text(
"✅ Подарок удалён.",
reply_markup=builder.as_markup()
)
await callback.message.edit_text("✅ Подарок удалён.", reply_markup=builder.as_markup())
+11 -15
View File
@@ -1,23 +1,21 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.models import Tariff, Gift
from ..panel.keyboard import AdminPanelCallback
from handlers.utils import get_plural_form, format_months, format_days
from database.models import Gift, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days, format_months, get_plural_form
from ..panel.keyboard import AdminPanelCallback
def build_admin_gifts_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🎁 Создать подарок", callback_data="admin_gift_create")
)
builder.row(InlineKeyboardButton(text="🎁 Создать подарок", callback_data="admin_gift_create"))
builder.row(
InlineKeyboardButton(text="📦 Все подарки", callback_data="admin_gifts_all"),
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data="admin")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="admin"))
return builder.as_markup()
@@ -66,8 +64,6 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
if nav:
builder.row(*nav)
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
return builder.as_markup()
return builder.as_markup()
+36 -61
View File
@@ -35,11 +35,7 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
text="🔑 Восстановить пробники",
callback_data=AdminPanelCallback(action="restore_trials").pack(),
)
maintenance_text = (
"🛠️ Выключить тех. работы"
if maintenance.maintenance_mode
else "🛠️ Включить тех. работы"
)
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
builder.button(
text=maintenance_text,
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
@@ -72,23 +68,16 @@ 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="database").pack())
return builder.as_markup()
def build_export_db_sources_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🌀 Remnawave",
callback_data=AdminPanelCallback(action="export_remnawave").pack()
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="back_to_db_menu").pack()
)
builder.button(text="🌀 Remnawave", callback_data=AdminPanelCallback(action="export_remnawave").pack())
builder.button(text="🧩 3x-ui", callback_data=AdminPanelCallback(action="request_3xui_file").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
@@ -99,14 +88,10 @@ def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
for tg_id, role in admins:
builder.button(
text=f"🧑 {tg_id} ({role})",
callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
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.button(text=" Добавить админа", callback_data=AdminPanelCallback(action="add_admin").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -115,26 +100,16 @@ def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
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"edit_role|{tg_id}").pack())
builder.button(
text="🗑 Удалить админа",
callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack()
)
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
if role == "superadmin":
builder.button(
text="🎟 Выпустить токен",
callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="admins").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
@@ -142,41 +117,41 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
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.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.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.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
return builder.as_markup()
def build_post_import_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔁 Перевыпустить подписки", callback_data=AdminPanelCallback(action="resync_after_import").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
+186 -110
View File
@@ -1,31 +1,46 @@
from aiogram import F, Router
import hashlib
import json
import os
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 sqlalchemy import func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
import hashlib
import time
from dateutil import parser
import os, subprocess, sys
import json
from aiogram import Bot
from panels.remnawave import RemnawaveAPI
from tempfile import NamedTemporaryFile
import traceback
from datetime import datetime
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Admin, Server, User
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.key_utils 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_management_kb, build_export_db_sources_kb, build_admins_kb, build_back_to_db_menu, build_single_admin_menu, build_role_selection_kb, build_database_kb, build_admin_back_kb_to_admins, build_token_result_kb
from asyncio import sleep
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
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()
@@ -35,6 +50,10 @@ class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
class Import3xuiStates(StatesGroup):
waiting_for_file = State()
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
@@ -43,9 +62,7 @@ class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
)
@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
@@ -62,9 +79,7 @@ async def handle_management(callback_query: CallbackQuery, session: AsyncSession
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter()
)
@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)
@@ -74,14 +89,10 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(
message: Message, state: FSMContext, session: AsyncSession
):
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(
f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'"
)
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
@@ -102,8 +113,8 @@ async def process_new_domain(
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))
(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)
@@ -146,26 +157,20 @@ async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession
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)
)
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)
)
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()
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
)
await state.set_state(AdminState.waiting_for_tg_id)
@@ -182,16 +187,9 @@ async def save_new_admin(message: Message, session: AsyncSession, state: FSMCont
if result.scalar_one_or_none():
await message.answer("⚠️ Такой админ уже существует.")
else:
session.add(Admin(
tg_id=tg_id,
role="moderator",
description="Добавлен вручную"
))
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 message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
await state.clear()
@@ -205,8 +203,7 @@ async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCall
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)
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
)
@@ -229,7 +226,7 @@ async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallb
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
f"<code>{token}</code>\n\n"
f"⚠️ Это сообщение исчезнет через 5 минут.",
reply_markup=build_token_result_kb(token)
reply_markup=build_token_result_kb(token),
)
await sleep(300)
@@ -243,8 +240,7 @@ async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallb
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)
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
)
@@ -261,8 +257,7 @@ async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallb
if tg_id == callback.from_user.id:
await callback.message.edit_text(
"🚫 <b>Нельзя изменить свою собственную роль!</b>",
reply_markup=build_single_admin_menu(tg_id)
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
)
return
@@ -276,8 +271,7 @@ async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallb
await session.commit()
await callback.message.edit_text(
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.",
reply_markup=build_single_admin_menu(tg_id)
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
)
@@ -289,8 +283,7 @@ async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallbac
await session.commit()
await callback.message.edit_text(
f"🗑 Админ <code>{tg_id}</code> удалён.",
reply_markup=build_admin_back_kb_to_admins()
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
)
@@ -314,7 +307,6 @@ async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
@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"):
@@ -336,45 +328,70 @@ async def restore_database(message: Message, state: FSMContext, bot: Bot):
logger.info(f"[Restore] Определён формат: {'custom' if is_custom_dump else 'plain'}")
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"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"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)
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)
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)
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"]
@@ -416,19 +433,14 @@ async def handle_export_db(callback: CallbackQuery):
@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()
)
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):
await callback.answer()
result = await session.execute(
select(Server).where(Server.panel_type == "remnawave", Server.enabled == True)
)
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled is True))
servers = result.scalars().all()
if not servers:
@@ -529,22 +541,14 @@ async def import_remnawave_keys(session: AsyncSession, users: list[dict], server
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)
)
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)
)
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,
@@ -571,4 +575,76 @@ async def import_remnawave_keys(session: AsyncSession, users: list[dict], server
await session.commit()
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
return 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>",
parse_mode="HTML",
reply_markup=build_back_to_db_menu(),
)
+5 -15
View File
@@ -33,20 +33,12 @@ def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="clusters").pack(),
)
builder.row(
InlineKeyboardButton(
text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()
),
InlineKeyboardButton(
text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()
),
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.row(
InlineKeyboardButton(
text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()
),
InlineKeyboardButton(
text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()
),
InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
)
if admin_role == "superadmin":
@@ -99,6 +91,4 @@ def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(
text=text, callback_data=AdminPanelCallback(action=action).pack()
)
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
+9 -15
View File
@@ -3,16 +3,17 @@ from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot import version
from filters.admin import IsAdminFilter
from database.models import Admin
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import version
from database.models import Admin
from filters.admin import IsAdminFilter
from logger import logger
from .keyboard import AdminPanelCallback, build_panel_kb
router = Router()
@@ -22,9 +23,7 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
await state.clear()
result = await session.execute(
select(Admin.role).where(Admin.tg_id == callback_query.from_user.id)
)
result = await session.execute(select(Admin.role).where(Admin.tg_id == callback_query.from_user.id))
role = result.scalar_one_or_none() or "admin"
markup = build_panel_kb(admin_role=role)
@@ -38,9 +37,7 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.warning(
"🔄 Попытка редактировать сообщение без изменений — пропущено."
)
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
else:
raise
else:
@@ -65,16 +62,13 @@ async def handle_admin_callback_query_simple(callback_query: CallbackQuery, stat
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{version}</blockquote>"
await state.clear()
result = await session.execute(
select(Admin.role).where(Admin.tg_id == message.from_user.id)
)
result = await session.execute(select(Admin.role).where(Admin.tg_id == message.from_user.id))
role = result.scalar_one_or_none() or "admin"
await message.answer(
text=text,
reply_markup=build_panel_kb(admin_role=role),
disable_web_page_preview=True,
)
)
+4 -6
View File
@@ -4,6 +4,7 @@ import subprocess
import sys
import psutil
from aiogram import F, Router
from aiogram.types import CallbackQuery
@@ -11,15 +12,12 @@ from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter()
)
async def handle_restart_confirm(
callback_query: CallbackQuery, callback_data: AdminPanelCallback
):
@router.callback_query(AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter())
async def handle_restart_confirm(callback_query: CallbackQuery, callback_data: AdminPanelCallback):
kb = build_admin_back_kb()
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
+28 -42
View File
@@ -1,12 +1,13 @@
import json
import re
from datetime import datetime
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,6 +18,7 @@ from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
@@ -32,23 +34,23 @@ def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
parts = text.split("BUTTONS:", 1)
clean_text = parts[0].strip()
buttons_text = parts[1].strip()
if not buttons_text:
return clean_text, None
buttons = []
button_lines = [line.strip() for line in buttons_text.split('\n') if line.strip()]
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
for line in button_lines:
try:
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r'\1', line)
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
button_data = json.loads(cleaned_line)
if not isinstance(button_data, dict) or "text" not in button_data:
logger.warning(f"Неверный формат кнопки: {line}")
continue
text_btn = button_data["text"]
if "callback" in button_data:
@@ -63,19 +65,19 @@ def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
else:
logger.warning(f"Кнопка без действия: {line}")
continue
buttons.append([button])
except json.JSONDecodeError as e:
logger.warning(f"Ошибка парсинга JSON кнопки: {line} - {e}")
continue
except Exception as e:
logger.error(f"Ошибка создания кнопки: {line} - {e}")
continue
if not buttons:
return clean_text, None
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard
@@ -151,42 +153,30 @@ async def handle_message_input(message: Message, state: FSMContext):
max_len = 1024 if photo else 4096
if len(clean_text) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\n"
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await state.update_data(
text=clean_text,
photo=photo,
keyboard=keyboard.model_dump() if keyboard else None
)
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
if photo:
await message.answer_photo(
photo=photo,
caption=clean_text,
parse_mode="HTML",
reply_markup=keyboard
)
await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
else:
await message.answer(
text=clean_text,
parse_mode="HTML",
reply_markup=keyboard
)
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
await message.answer(
"👀 Это предпросмотр рассылки.\nОтправить?",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
InlineKeyboardButton(text=" Отмена", callback_data="cancel_message"),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
]
]
]),
),
)
@@ -226,9 +216,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(
~User.tg_id.in_(subquery) & User.trial.in_([0, -1])
)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
@@ -253,9 +241,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
total_users = len(tg_ids)
success_count = 0
await callback_query.message.edit_text(
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
)
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
for tg_id in tg_ids:
try:
@@ -296,4 +282,4 @@ async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
await state.clear()
+23 -74
View File
@@ -1,5 +1,5 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
@@ -10,10 +10,7 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
data: str
def build_manage_server_kb(
server_name: str, cluster_name: str, enabled: bool
) -> InlineKeyboardMarkup:
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
@@ -21,9 +18,7 @@ def build_manage_server_kb(
builder.button(
text=toggle_text,
callback_data=AdminServerCallback(
action=toggle_action, data=server_name
).pack(),
callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack(),
)
builder.button(
@@ -54,55 +49,30 @@ def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKe
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📝 Имя сервера",
callback_data=f"edit_server_field|{server_name}|server_name"
)
)
builder.row(
InlineKeyboardButton(
text="🗂 Кластер",
callback_data=f"edit_server_field|{server_name}|cluster_name"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 API URL",
callback_data=f"edit_server_field|{server_name}|api_url"
)
InlineKeyboardButton(text="📝 Имя сервера", callback_data=f"edit_server_field|{server_name}|server_name")
)
builder.row(InlineKeyboardButton(text="🗂 Кластер", callback_data=f"edit_server_field|{server_name}|cluster_name"))
builder.row(InlineKeyboardButton(text="🌐 API URL", callback_data=f"edit_server_field|{server_name}|api_url"))
if server_data.get("subscription_url"):
builder.row(
InlineKeyboardButton(
text="📡 Subscription URL",
callback_data=f"edit_server_field|{server_name}|subscription_url"
text="📡 Subscription URL", callback_data=f"edit_server_field|{server_name}|subscription_url"
)
)
builder.row(InlineKeyboardButton(text="🔑 Inbound ID", callback_data=f"edit_server_field|{server_name}|inbound_id"))
builder.row(InlineKeyboardButton(text="⚙️ Тип панели", callback_data=f"select_panel_type|{server_name}"))
builder.row(
InlineKeyboardButton(
text="🔑 Inbound ID",
callback_data=f"edit_server_field|{server_name}|inbound_id"
text="⬅️ Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
builder.row(
InlineKeyboardButton(
text="⚙️ Тип панели",
callback_data=f"select_panel_type|{server_name}"
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
return builder.as_markup()
@@ -111,44 +81,23 @@ def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
inline_keyboard=[
[InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
[InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"edit_server|{server_name}"
)]
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}")],
]
)
def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for cluster in clusters:
builder.row(
InlineKeyboardButton(
text=cluster,
callback_data=f"set_cluster|{server_name}|{cluster}"
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"edit_server|{server_name}"
)
)
builder.row(InlineKeyboardButton(text=cluster, callback_data=f"set_cluster|{server_name}|{cluster}"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}"))
return builder.as_markup()
def build_cancel_edit_kb(server_name: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отменить",
callback_data=f"edit_server|{server_name}"
)
]
]
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data=f"edit_server|{server_name}")]]
)
+65 -131
View File
@@ -9,25 +9,26 @@ 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,
get_available_clusters,
update_server_cluster,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_manage_server_kb,
build_edit_server_fields_kb,
build_panel_type_selection_kb,
build_cluster_selection_kb,
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()
@@ -40,9 +41,7 @@ class ServerEditState(StatesGroup):
editing_value = State()
@router.callback_query(
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
)
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_server_manage(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
@@ -52,12 +51,7 @@ async def handle_server_manage(
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name),
(None, None),
)
@@ -69,9 +63,7 @@ async def handle_server_manage(
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
result = await session.execute(
select(func.count()).where(Key.server_id == server_name)
)
result = await session.execute(select(func.count()).where(Key.server_id == server_name))
subscription_count = result.scalar() or 0
text = (
@@ -97,17 +89,13 @@ async def handle_server_manage(
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@router.callback_query(
AdminServerCallback.filter(F.action == "delete"), IsAdminFilter()
)
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
async def process_callback_delete_server(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
@@ -139,16 +127,9 @@ async def process_callback_delete_server(
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(Key.server_id == Server.server_name).correlate(Server).scalar_subquery()
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(
Server.server_name != server_name
)
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(Server.server_name != server_name)
result = await session.execute(stmt_all_servers)
all_servers = result.all()
@@ -164,9 +145,7 @@ async def process_callback_delete_server(
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(
action="manage", data=server_name
).pack(),
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
)
@@ -184,31 +163,20 @@ async def process_callback_delete_server(
remaining_servers = result.scalar_one()
if remaining_servers == 0:
stmt_other_clusters = (
select(Server.cluster_name)
.distinct()
.where(Server.cluster_name != cluster_name)
)
stmt_other_clusters = select(Server.cluster_name).distinct().where(Server.cluster_name != cluster_name)
result = await session.execute(stmt_other_clusters)
other_clusters = result.scalars().all()
if other_clusters:
stmt_cluster_keys = select(func.count()).where(
Key.server_id == cluster_name
)
stmt_cluster_keys = select(func.count()).where(Key.server_id == cluster_name)
result = await session.execute(stmt_cluster_keys)
cluster_keys_count = result.scalar_one()
if cluster_keys_count > 0:
await state.update_data(
server_name=server_name, cluster_name=cluster_name
)
await state.update_data(server_name=server_name, cluster_name=cluster_name)
subq_cluster = (
select(func.count())
.where(Key.server_id == Server.cluster_name)
.correlate(Server)
.scalar_subquery()
select(func.count()).where(Key.server_id == Server.cluster_name).correlate(Server).scalar_subquery()
)
stmt_all_clusters = (
@@ -230,9 +198,7 @@ async def process_callback_delete_server(
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(
action="manage", data=server_name
).pack(),
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
)
@@ -243,9 +209,7 @@ async def process_callback_delete_server(
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 = 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(
@@ -253,9 +217,7 @@ async def process_callback_delete_server(
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 = 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(
@@ -264,9 +226,7 @@ async def process_callback_delete_server(
)
@router.callback_query(
AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter()
)
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
async def toggle_server_enabled(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
@@ -277,22 +237,13 @@ async def toggle_server_enabled(
new_status = action == "enable"
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(enabled=new_status)
)
await session.execute(update(Server).where(Server.server_name == server_name).values(enabled=new_status))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name),
(None, None),
)
@@ -313,18 +264,12 @@ async def toggle_server_enabled(
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=new_status
),
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
)
@router.callback_query(
AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter()
)
async def ask_server_limit(
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext
):
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
async def ask_server_limit(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
server_name = callback_data.data
await state.set_state(ServerLimitState.waiting_for_limit)
await state.update_data(server_name=server_name)
@@ -334,9 +279,7 @@ async def ask_server_limit(
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
async def save_server_limit(
message: types.Message, state: FSMContext, session: AsyncSession
):
async def save_server_limit(message: types.Message, state: FSMContext, session: AsyncSession):
try:
limit = int(message.text.strip())
if limit < 0:
@@ -347,21 +290,12 @@ async def save_server_limit(
new_value = limit if limit > 0 else None
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(max_keys=new_value)
)
await session.execute(update(Server).where(Server.server_name == server_name).values(max_keys=new_value))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name),
(None, None),
)
@@ -383,9 +317,7 @@ async def save_server_limit(
await message.answer(
text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
)
await state.clear()
@@ -398,69 +330,69 @@ async def start_edit_server(callback: CallbackQuery, state: FSMContext, session:
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)
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)
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",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_edit_kb(server_name)
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)
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()
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
@@ -469,15 +401,16 @@ async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
@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()
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении кластера")
@@ -499,7 +432,7 @@ async def apply_field_edit(message: types.Message, state: FSMContext, session: A
return
else:
success = await update_server_field(session, server_name, field, value)
if success:
field_names = {
"server_name": "имя сервера",
@@ -507,15 +440,16 @@ async def apply_field_edit(message: types.Message, state: FSMContext, session: A
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID",
}
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()
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await message.answer("❌ Ошибка при изменении поля")
await state.clear()
+1 -3
View File
@@ -6,9 +6,7 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_stats_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()
)
builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
builder.button(
text="📥 Выгрузить пользователей в CSV",
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
+33 -70
View File
@@ -1,17 +1,18 @@
from collections import Counter
from datetime import datetime, timedelta
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from collections import Counter
from bot import bot
from config import ADMIN_ID
from database import (
count_active_keys,
count_hot_leads,
count_total_keys,
count_total_referrals,
count_total_users,
@@ -20,13 +21,12 @@ from database import (
count_users_registered_since,
count_users_updated_today,
get_tariff_distribution,
get_tariff_names,
get_tariff_groups,
get_tariff_durations,
get_tariff_groups,
get_tariff_names,
sum_payments_between,
sum_payments_since,
sum_total_payments,
count_hot_leads
)
from filters.admin import IsAdminFilter
from logger import logger
@@ -40,6 +40,7 @@ from utils.csv_export import (
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_stats_kb
router = Router()
@@ -53,39 +54,37 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
total_users = await count_total_users(session)
today_start = moscow_tz.localize(datetime.combine(today, datetime.min.time()))
today_start_utc = today_start.astimezone(pytz.UTC).replace(tzinfo=None)
users_updated_today = await count_users_updated_today(session, today_start_utc)
registrations_today = await count_users_registered_since(session, today_start_utc)
yesterday_date = today - timedelta(days=1)
yesterday_start = moscow_tz.localize(datetime.combine(yesterday_date, datetime.min.time()))
yesterday_end = moscow_tz.localize(datetime.combine(today, datetime.min.time()))
yesterday_start_utc = yesterday_start.astimezone(pytz.UTC).replace(tzinfo=None)
yesterday_end_utc = yesterday_end.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_yesterday = await count_users_registered_between(
session, yesterday_start_utc, yesterday_end_utc
)
registrations_yesterday = await count_users_registered_between(session, yesterday_start_utc, yesterday_end_utc)
week_start_date = today - timedelta(days=today.weekday())
week_start = moscow_tz.localize(datetime.combine(week_start_date, datetime.min.time()))
week_start_utc = week_start.astimezone(pytz.UTC).replace(tzinfo=None)
month_start_date = today.replace(day=1)
month_start = moscow_tz.localize(datetime.combine(month_start_date, datetime.min.time()))
month_start_utc = month_start.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_week = await count_users_registered_since(session, week_start_utc)
registrations_month = await count_users_registered_since(session, month_start_utc)
last_month_start_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
this_month_start_date = today.replace(day=1)
last_month_start = moscow_tz.localize(datetime.combine(last_month_start_date, datetime.min.time()))
last_month_end = moscow_tz.localize(datetime.combine(this_month_start_date, datetime.min.time()))
last_month_start_utc = last_month_start.astimezone(pytz.UTC).replace(tzinfo=None)
last_month_end_utc = last_month_end.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_last_month = await count_users_registered_between(
session, last_month_start_utc, last_month_end_utc
)
@@ -130,43 +129,31 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
"Без тарифа: 3 мес": 2,
"Без тарифа: 6 мес": 3,
"Без тарифа: 12 мес": 4,
"Без тарифа: прочее": 5
"Без тарифа: прочее": 5,
}
sorted_buckets = sorted(
duration_buckets.items(),
key=lambda x: bucket_order.get(x[0], 999)
)
sorted_buckets = sorted(duration_buckets.items(), key=lambda x: bucket_order.get(x[0], 999))
for name, count in sorted_buckets:
tariff_stats_text += f"{name}: <b>{count}</b>\n"
for group, tariffs in grouped_tariffs.items():
tariff_stats_text += f"Тариф {group}\n"
sorted_tariffs = sorted(
tariffs,
key=lambda x: tariff_durations.get(x[0], 0)
)
sorted_tariffs = sorted(tariffs, key=lambda x: tariff_durations.get(x[0], 0))
for tid, count in sorted_tariffs:
name = tariff_names.get(tid, f"ID {tid}")
tariff_stats_text += f"{name}: <b>{count}</b>\n"
tariff_stats_text = (
"└ По тарифам и срокам:\n" + tariff_stats_text
if tariff_stats_text
else "└ Нет данных по тарифам\n"
"└ По тарифам и срокам:\n" + tariff_stats_text if tariff_stats_text else "└ Нет данных по тарифам\n"
)
total_referrals = await count_total_referrals(session)
total_payments_today = await sum_payments_since(session, today_start_utc)
total_payments_yesterday = await sum_payments_between(
session, yesterday_start_utc, yesterday_end_utc
)
total_payments_yesterday = await sum_payments_between(session, yesterday_start_utc, yesterday_end_utc)
total_payments_week = await sum_payments_since(session, week_start_utc)
total_payments_month = await sum_payments_since(session, month_start_utc)
total_payments_last_month = await sum_payments_between(
session, last_month_start_utc, last_month_end_utc
)
total_payments_last_month = await sum_payments_between(session, last_month_start_utc, last_month_end_utc)
total_payments_all_time = await sum_total_payments(session)
hot_leads_count = await count_hot_leads(session)
@@ -208,79 +195,55 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
await callback_query.message.edit_text(
text=stats_message, reply_markup=build_stats_kb()
)
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
except TelegramBadRequest as e:
if "message is not modified" not in str(e):
logger.error(f"Error in user_stats_menu: {e}")
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
await callback_query.answer(
"Произошла ошибка при получении статистики", show_alert=True
)
await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter())
async def handle_export_users_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт пользователей в CSV"
)
await callback_query.message.answer_document(document=export, caption="📅 Экспорт пользователей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter()
)
async def handle_export_payments_csv(
callback_query: CallbackQuery, session: AsyncSession
):
@router.callback_query(AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter())
async def handle_export_payments_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт платежей в CSV"
)
await callback_query.message.answer_document(document=export, caption="📅 Экспорт платежей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте платежей: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter()
)
async def handle_export_hot_leads_csv(
callback_query: CallbackQuery, session: AsyncSession
):
@router.callback_query(AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter())
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_hot_leads_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт горящих лидов"
)
await callback_query.message.answer_document(document=export, caption="📅 Экспорт горящих лидов")
except Exception as e:
logger.error(f"Ошибка при экспорте горящих лидов: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter())
async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_keys_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт подписок в CSV"
)
await callback_query.message.answer_document(document=export, caption="📅 Экспорт подписок в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте подписок: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
+14 -43
View File
@@ -1,9 +1,11 @@
from collections import defaultdict
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from collections import defaultdict
from database.tariffs import create_subgroup_hash
from ..panel.keyboard import AdminPanelCallback
@@ -25,23 +27,13 @@ def build_tariff_menu_kb() -> InlineKeyboardMarkup:
callback_data=AdminTariffCallback(action="list").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()))
return builder.as_markup()
def build_cancel_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отменить", callback_data="cancel_tariff_creation"
)
]
]
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_tariff_creation")]]
)
@@ -81,13 +73,12 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
subgroup = t.get("subgroup_title")
grouped[subgroup].append(t)
for subgroup_title, items in grouped.items():
for subgroup_title, _items in grouped.items():
if subgroup_title:
subgroup_hash = create_subgroup_hash(subgroup_title, group_code)
builder.row(
InlineKeyboardButton(
text=f"{subgroup_title}",
callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
)
@@ -107,12 +98,7 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
)
)
builder.row(
InlineKeyboardButton(
text="Сгруппировать в подгруппу",
callback_data=f"start_subgrouping|{group_code}"
)
)
builder.row(InlineKeyboardButton(text="Сгруппировать в подгруппу", callback_data=f"start_subgrouping|{group_code}"))
builder.row(
InlineKeyboardButton(
@@ -130,15 +116,11 @@ def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=AdminTariffCallback(
action=f"edit|{tariff_id}"
).pack(),
callback_data=AdminTariffCallback(action=f"edit|{tariff_id}").pack(),
),
InlineKeyboardButton(
text="🗑 Удалить",
callback_data=AdminTariffCallback(
action=f"delete|{tariff_id}"
).pack(),
callback_data=AdminTariffCallback(action=f"delete|{tariff_id}").pack(),
),
],
[
@@ -154,22 +136,14 @@ def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Название", callback_data=f"edit_field|{tariff_id}|name"
)
],
[InlineKeyboardButton(text="📝 Название", callback_data=f"edit_field|{tariff_id}|name")],
[
InlineKeyboardButton(
text="📅 Длительность",
callback_data=f"edit_field|{tariff_id}|duration_days",
)
],
[
InlineKeyboardButton(
text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub"
)
],
[InlineKeyboardButton(text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub")],
[
InlineKeyboardButton(
text="📦 Трафик (ГБ или 0)",
@@ -182,14 +156,11 @@ def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
callback_data=f"edit_field|{tariff_id}|device_limit",
)
],
[InlineKeyboardButton(text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}")],
[
InlineKeyboardButton(
text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}"
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
)
],
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
)]
]
)
+117 -219
View File
@@ -29,6 +29,7 @@ from .keyboard import (
build_tariff_menu_kb,
)
router = Router()
@@ -74,30 +75,24 @@ def validate_subgroup_title(title: str) -> tuple[bool, str]:
return True, ""
@router.callback_query(
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
)
@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"
"• Установите длительность (в днях)\n"
"<blockquote>• Установите длительность (в днях)\n"
"• Задайте цену (в рублях)\n"
"• Задайте лимит устройств (hwid/ip_limit)\n"
"• Укажите лимит трафика (в ГБ)\n\n"
"• Укажите лимит трафика (в ГБ)</blockquote>\n\n"
"<b>📋 Редактировать тарифы</b>\n"
"• Просматривайте список текущих тарифов\n"
"• Изменяйте параметры или удаляйте при необходимости"
)
await callback_query.message.edit_text(
text=text, reply_markup=build_tariff_menu_kb()
"<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()
)
@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(
@@ -135,7 +130,7 @@ async def process_tariff_name(message: Message, state: FSMContext):
reply_markup=build_cancel_kb(),
)
return
await state.update_data(name=name)
await state.set_state(TariffCreateState.duration)
await message.answer(
@@ -151,9 +146,7 @@ async def process_tariff_duration(message: Message, state: FSMContext):
if days <= 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректное количество дней (целое число больше 0):"
)
await message.answer("❌ Введите корректное количество дней (целое число больше 0):")
return
await state.update_data(duration_days=days)
@@ -190,9 +183,7 @@ async def process_tariff_traffic(message: Message, state: FSMContext):
if traffic < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит трафика (целое число 0 или больше):"
)
await message.answer("❌ Введите корректный лимит трафика (целое число 0 или больше):")
return
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
@@ -204,17 +195,13 @@ async def process_tariff_traffic(message: Message, state: FSMContext):
@router.message(TariffCreateState.device_limit, IsAdminFilter())
async def process_tariff_device_limit(
message: Message, state: FSMContext, session: AsyncSession
):
async def process_tariff_device_limit(message: Message, state: FSMContext, session: AsyncSession):
try:
device_limit = int(message.text.strip())
if device_limit < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит устройств (целое число 0 или больше):"
)
await message.answer("❌ Введите корректный лимит устройств (целое число 0 или больше):")
return
data = await state.get_data()
@@ -239,9 +226,7 @@ async def process_tariff_device_limit(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
InlineKeyboardButton(
text="❌ Нет", callback_data="done_tariff_group"
),
InlineKeyboardButton(text="❌ Нет", callback_data="done_tariff_group"),
]
]
),
@@ -251,40 +236,30 @@ async def process_tariff_device_limit(
@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()
)
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()
)
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()
)
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)
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()
)
await callback.message.edit_text("❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb())
return
special_groups = {
@@ -310,21 +285,15 @@ def tariff_to_dict(tariff: Tariff) -> dict:
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
"subgroup_title": tariff.subgroup_title
"subgroup_title": tariff.subgroup_title,
}
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter()
)
async def show_tariffs_in_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
group_code = callback_data.action.split("|", 1)[1]
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
)
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
tariffs = result.scalars().all()
if not tariffs:
@@ -339,12 +308,8 @@ async def show_tariffs_in_group(
)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter()
)
async def view_tariff(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
@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)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
@@ -358,24 +323,16 @@ async def view_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
):
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback):
tariff_id = int(callback_data.action.split("|", 1)[1])
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=f"view|{tariff_id}"
),
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view|{tariff_id}"),
]
]
),
@@ -395,36 +352,22 @@ async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
group_code = tariff.group_code
await session.execute(
update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None)
)
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)
)
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.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()
)
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
):
@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)
@@ -519,9 +462,7 @@ async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter()
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter())
async def start_tariff_creation_existing_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
@@ -535,12 +476,8 @@ async def start_tariff_creation_existing_group(
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 "Безлимит"
)
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 "Безлимит"
text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
@@ -561,10 +498,7 @@ async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session:
result = await session.execute(
select(Tariff)
.where(
Tariff.group_code == group_code,
(Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == ""))
)
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
@@ -574,38 +508,30 @@ async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session:
"❌ Нет доступных тарифов для группировки.\n\nВсе тарифы уже находятся в подгруппах.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
)]
[
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=[]
)
await state.update_data(group_code=group_code, selected_tariff_ids=[])
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.row(
InlineKeyboardButton(
text=f"{tariff.name}",
callback_data=f"sub_select|{tariff.id}"
)
)
builder.row(InlineKeyboardButton(text=f"{tariff.name}", callback_data=f"sub_select|{tariff.id}"))
builder.row(
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
)
await callback.message.edit_text(
"Выберите тарифы, которые нужно объединить в подгруппу:",
reply_markup=builder.as_markup()
"Выберите тарифы, которые нужно объединить в подгруппу:", reply_markup=builder.as_markup()
)
@@ -625,10 +551,7 @@ async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMCo
group_code = data["group_code"]
result = await session.execute(
select(Tariff)
.where(
Tariff.group_code == group_code,
(Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == ""))
)
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
@@ -637,16 +560,11 @@ async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMCo
for tariff in tariffs:
is_selected = tariff.id in selected
prefix = "" if is_selected else ""
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.name}",
callback_data=f"sub_select|{tariff.id}"
)
)
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"sub_select|{tariff.id}"))
builder.row(
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
)
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
@@ -666,9 +584,7 @@ async def ask_subgroup_title(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffSubgroupState.entering_subgroup_title)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]
]
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
)
await callback.message.edit_text(
@@ -686,13 +602,11 @@ async def apply_subgroup_title(message: Message, state: FSMContext, session: Asy
await message.answer(
f"{error_msg}\n\nПовторите ввод:",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]
]
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
),
)
return
data = await state.get_data()
selected_ids = data.get("selected_tariff_ids", [])
@@ -702,14 +616,15 @@ async def apply_subgroup_title(message: Message, state: FSMContext, session: Asy
return
await session.execute(
update(Tariff)
.where(Tariff.id.in_(selected_ids))
.values(subgroup_title=title, updated_at=datetime.utcnow())
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())
await message.answer(
f"{len(selected_ids)} тарифов сгруппированы в подгруппу: <b>{title}</b>.",
reply_markup=build_tariff_menu_kb(),
)
@router.callback_query(F.data == "cancel_subgrouping", IsAdminFilter())
@@ -723,16 +638,15 @@ 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
result = await session.execute(
select(Tariff).where(
Tariff.group_code == group_code,
Tariff.subgroup_title == subgroup_title
).order_by(Tariff.id)
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
@@ -789,7 +703,7 @@ async def start_rename_subgroup(callback: CallbackQuery, state: FSMContext, sess
_, 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
@@ -807,7 +721,7 @@ async def start_rename_subgroup(callback: CallbackQuery, state: FSMContext, sess
inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
]
)
),
)
@@ -820,17 +734,21 @@ async def save_new_subgroup_title(message: Message, state: FSMContext, session:
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}")]
[
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"]
@@ -846,15 +764,19 @@ async def save_new_subgroup_title(message: Message, state: FSMContext, session:
await session.commit()
await state.clear()
new_hash = create_subgroup_hash(new_title, group_code)
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())]
[
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
)
]
]
)
),
)
@@ -863,7 +785,7 @@ async def confirm_delete_subgroup(callback: CallbackQuery, state: FSMContext, se
_, 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
@@ -882,10 +804,10 @@ async def confirm_delete_subgroup(callback: CallbackQuery, state: FSMContext, se
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Удалить", callback_data="confirm_subgroup_deletion"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
]
]
)
),
)
@@ -897,10 +819,7 @@ async def perform_subgroup_deletion(callback: CallbackQuery, state: FSMContext,
await session.execute(
update(Tariff)
.where(
Tariff.group_code == group_code,
Tariff.subgroup_title == subgroup_title
)
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
.values(subgroup_title=None)
)
await session.commit()
@@ -910,9 +829,13 @@ async def perform_subgroup_deletion(callback: CallbackQuery, state: FSMContext,
f"✅ Подгруппа <b>{subgroup_title}</b> удалена.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack())]
[
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
)
]
]
)
),
)
@@ -921,7 +844,7 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
_, 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
@@ -930,11 +853,7 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
select(Tariff)
.where(
Tariff.group_code == group_code,
or_(
Tariff.subgroup_title == subgroup_title,
Tariff.subgroup_title.is_(None),
Tariff.subgroup_title == ''
)
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
)
.order_by(Tariff.id)
)
@@ -947,12 +866,9 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
"❌ Нет доступных тарифов для редактирования.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)]
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
]
)
),
)
return
@@ -961,30 +877,25 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
subgroup_title=subgroup_title,
group_code=group_code,
subgroup_hash=subgroup_hash,
selected_tariff_ids=list(subgroup_tariff_ids)
selected_tariff_ids=list(subgroup_tariff_ids),
)
builder = InlineKeyboardBuilder()
for tariff in all_tariffs_to_show:
is_in_subgroup = tariff.id in subgroup_tariff_ids
prefix = "" if is_in_subgroup else ""
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.name}",
callback_data=f"edit_sub_toggle|{tariff.id}"
)
)
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"edit_sub_toggle|{tariff.id}"))
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")
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()
reply_markup=builder.as_markup(),
)
@@ -1009,11 +920,7 @@ async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMCont
select(Tariff)
.where(
Tariff.group_code == group_code,
or_(
Tariff.subgroup_title == subgroup_title,
Tariff.subgroup_title.is_(None),
Tariff.subgroup_title == ''
)
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
)
.order_by(Tariff.id)
)
@@ -1023,16 +930,11 @@ async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMCont
for tariff in all_tariffs_to_show:
is_selected = tariff.id in selected_ids
prefix = "" if is_selected else ""
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.name}",
callback_data=f"edit_sub_toggle|{tariff.id}"
)
)
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"edit_sub_toggle|{tariff.id}"))
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
)
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
@@ -1047,11 +949,7 @@ async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMConte
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
)
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}
@@ -1061,9 +959,7 @@ async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMConte
if to_remove:
await session.execute(
update(Tariff)
.where(Tariff.id.in_(to_remove))
.values(subgroup_title=None, updated_at=datetime.utcnow())
update(Tariff).where(Tariff.id.in_(to_remove)).values(subgroup_title=None, updated_at=datetime.utcnow())
)
if to_add:
@@ -1081,12 +977,14 @@ async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMConte
f"✅ Подгруппа <b>{subgroup_title}</b> была расформирована.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="⬅️ Назад к группе тарифов",
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
)]
[
InlineKeyboardButton(
text="⬅️ Назад к группе тарифов",
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
)
]
]
)
),
)
return
@@ -1113,14 +1011,14 @@ async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMConte
changes_text.append("Изменений не было")
await callback.message.edit_text(
f"✅ <b>Подгруппа обновлена: {subgroup_title}</b>\n\n"
f"{chr(10).join(changes_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}"
)]
[
InlineKeyboardButton(
text="⬅️ Назад к подгруппе", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
]
]
)
),
)
+38 -110
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select, or_
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import HWID_RESET_BUTTON
@@ -30,18 +30,14 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
edit: bool = False
def build_user_edit_kb(
tg_id: int, key_records: list, is_banned: bool = False
) -> InlineKeyboardMarkup:
def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
builder.row(
InlineKeyboardButton(
text=" Создать подписку",
callback_data=AdminUserEditorCallback(
action="users_create_key", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
)
)
@@ -52,51 +48,39 @@ def build_user_edit_kb(
builder.row(
InlineKeyboardButton(
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=str(email)
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✉️ Сообщение",
callback_data=AdminUserEditorCallback(
action="users_send_message", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="💸 Баланс",
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🤝 Выгрузить рефералов",
callback_data=AdminUserEditorCallback(
action="users_export_referrals", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_export_referrals", tg_id=tg_id).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(
action="users_trial_restore", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(
action="users_delete_user", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
@@ -116,55 +100,41 @@ def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
)
return builder.as_markup()
async def build_users_balance_kb(
session: AsyncSession, tg_id: int
) -> InlineKeyboardMarkup:
async def build_users_balance_kb(session: AsyncSession, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for amount in [100, 250, 500, 1000]:
builder.row(
InlineKeyboardButton(
text=f"+ {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=amount
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
),
InlineKeyboardButton(
text=f"- {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=-amount
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-amount).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Добавить",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="💵 Вычесть",
callback_data=AdminUserEditorCallback(
action="users_balance_take", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(
action="users_balance_set", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
)
@@ -177,21 +147,17 @@ def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email, edit=True
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
)
return builder.as_markup()
async def build_users_key_expiry_kb(
session: AsyncSession, tg_id: int, email: str
) -> InlineKeyboardMarkup:
async def build_users_key_expiry_kb(session: AsyncSession, tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
result = await session.execute(select(Key.server_id, Key.tariff_id).where(Key.email == email))
row = result.first()
server_id, tariff_id = (row if row else (None, None))
_server_id, tariff_id = row if row else (None, None)
if tariff_id:
result = await session.execute(select(Tariff.group_code).where(Tariff.id == tariff_id))
@@ -199,8 +165,7 @@ async def build_users_key_expiry_kb(
if row and row[0]:
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
)
tariffs = result.scalars().all()
unique_durations = set()
@@ -228,32 +193,24 @@ async def build_users_key_expiry_kb(
builder.row(
InlineKeyboardButton(
text="⏳ Добавить дни",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email
).pack(),
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack(),
),
InlineKeyboardButton(
text="⏳ Вычесть дни",
callback_data=AdminUserKeyEditorCallback(
action="take", tg_id=tg_id, data=email
).pack(),
callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(
action="set", tg_id=tg_id, data=email
).pack(),
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
)
@@ -264,9 +221,7 @@ def build_user_delete_kb(tg_id: int):
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Да, удалить!",
callback_data=AdminUserEditorCallback(
action="users_delete_user_confirm", tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_delete_user_confirm", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(1)
@@ -277,9 +232,7 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(1)
return builder.as_markup()
@@ -296,27 +249,19 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
)
builder.button(
text="🔄 Перевыпустить",
callback_data=AdminUserEditorCallback(
action="users_update_key", data=email, tg_id=key_details["tg_id"]
).pack(),
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
text="📦 Тариф",
callback_data=AdminUserEditorCallback(
action="users_renew", data=email, tg_id=key_details["tg_id"]
).pack(),
callback_data=AdminUserEditorCallback(action="users_renew", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(
action="users_delete_key", data=email, tg_id=key_details["tg_id"]
).pack(),
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
text="📊 Трафик",
callback_data=AdminUserEditorCallback(
action="users_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
text="♻️ Сбросить трафик",
@@ -341,15 +286,11 @@ def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="♻️ Сбросить HWID",
callback_data=AdminUserEditorCallback(
action="users_hwid_reset", data=email, tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_hwid_reset", data=email, tg_id=tg_id).pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(),
)
builder.adjust(1)
return builder.as_markup()
@@ -360,9 +301,7 @@ def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=AdminUserEditorCallback(
action="users_delete_key_confirm", data=email, tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(),
)
)
builder.row(build_editor_back_btn(tg_id))
@@ -374,9 +313,7 @@ def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
return build_editor_singleton_kb(BACK, tg_id, edit)
def build_editor_singleton_kb(
text: str, tg_id: int, edit: bool = False
) -> InlineKeyboardMarkup:
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_editor_btn(text, tg_id, edit))
return builder.as_markup()
@@ -389,28 +326,19 @@ def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButto
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
return InlineKeyboardButton(
text=text,
callback_data=AdminUserEditorCallback(
action="users_editor", tg_id=tg_id, edit=edit
).pack(),
callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack(),
)
async def build_cluster_selection_kb(
session, tg_id: int, email: str, action: str
) -> InlineKeyboardMarkup:
async def build_cluster_selection_kb(session, tg_id: int, email: str, action: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
clusters = await get_clusters(session)
for cluster_id in clusters:
builder.button(
text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}"
)
builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}")
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack()
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
)
builder.adjust(1)
return builder.as_markup()
+115 -305
View File
@@ -1,9 +1,11 @@
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
import pytz
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -53,6 +55,7 @@ from .keyboard import (
AdminUserEditorCallback,
AdminUserKeyEditorCallback,
build_cluster_selection_kb,
build_editor_btn,
build_editor_kb,
build_hwid_menu_kb,
build_key_delete_kb,
@@ -63,9 +66,9 @@ from .keyboard import (
build_users_balance_kb,
build_users_key_expiry_kb,
build_users_key_show_kb,
build_editor_btn
)
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
router = Router()
@@ -93,9 +96,7 @@ class BanUserStates(StatesGroup):
waiting_for_ban_duration = State()
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_hwid_menu"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_hwid_menu"), IsAdminFilter())
async def handle_hwid_menu(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -106,9 +107,7 @@ async def handle_hwid_menu(
client_id = await get_client_id_by_email(session, email)
if not client_id:
await callback_query.message.edit_text(
"🚫 Не удалось найти client_id по email."
)
await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.")
return
servers = await get_servers(session=session)
@@ -123,8 +122,7 @@ async def handle_hwid_menu(
if not remna_server:
await callback_query.message.edit_text(
"🚫 Нет доступного сервера Remnawave.",
reply_markup=build_editor_kb(tg_id)
"🚫 Нет доступного сервера Remnawave.", reply_markup=build_editor_kb(tg_id)
)
return
@@ -151,14 +149,10 @@ async def handle_hwid_menu(
f"└ 🔄 <b>Обновлено:</b> {updated}\n\n"
)
await callback_query.message.edit_text(
text, reply_markup=build_hwid_menu_kb(email, tg_id)
)
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()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_hwid_reset"), IsAdminFilter())
async def handle_hwid_reset(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -169,9 +163,7 @@ async def handle_hwid_reset(
client_id = await get_client_id_by_email(session, email)
if not client_id:
await callback_query.message.edit_text(
"🚫 Не удалось найти client_id по email."
)
await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.")
return
servers = await get_servers(session=session)
@@ -186,8 +178,7 @@ async def handle_hwid_reset(
if not remna_server:
await callback_query.message.edit_text(
"🚫 Нет доступного сервера Remnawave.",
reply_markup=build_editor_kb(tg_id)
"🚫 Нет доступного сервера Remnawave.", reply_markup=build_editor_kb(tg_id)
)
return
@@ -229,9 +220,7 @@ async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
)
await state.set_state(UserEditorState.waiting_for_user_data)
await callback_query.message.edit_text(
text=text, reply_markup=build_admin_back_kb()
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
@router.callback_query(
@@ -240,9 +229,7 @@ async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
)
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()
)
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
@@ -250,27 +237,21 @@ async def handle_key_name_input(message: Message, state: FSMContext, session: An
kb = build_admin_back_kb()
if not message.text:
await message.answer(
text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb
)
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
)
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
):
async def handle_user_data_input(message: Message, state: FSMContext, session: AsyncSession):
kb = build_admin_back_kb()
if message.forward_from:
@@ -279,9 +260,7 @@ async def handle_user_data_input(
return
if not message.text:
await message.answer(
text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb
)
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
@@ -341,8 +320,7 @@ async def handle_message_text_input(message: Message, state: FSMContext):
max_len = 1024 if photo else 4096
if len(text_message) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\n"
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
@@ -358,12 +336,14 @@ async def handle_message_text_input(message: Message, state: FSMContext):
await message.answer(
"👀 Это предпросмотр сообщения. Отправить?",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_user_message"),
InlineKeyboardButton(text=" Отмена", callback_data="cancel_user_message"),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_user_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_user_message"),
]
]
]),
),
)
@@ -402,9 +382,7 @@ async def handle_send_user_message(callback_query: CallbackQuery, state: FSMCont
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 callback_query.message.edit_text(text="🚫 Отправка сообщения отменена.", reply_markup=build_editor_kb(tg_id))
await state.clear()
@@ -420,14 +398,10 @@ async def handle_trial_restore(
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)
)
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()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_edit"), IsAdminFilter())
async def handle_balance_change(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -436,9 +410,7 @@ async def handle_balance_change(
tg_id = callback_data.tg_id
stmt = (
select(
Payment.amount, Payment.payment_system, Payment.status, Payment.created_at
)
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)
@@ -467,14 +439,10 @@ async def handle_balance_change(
else:
text += "\n <i>🚫 Отсутствуют</i>"
await callback_query.message.edit_text(
text=text, reply_markup=await build_users_balance_kb(session, tg_id)
)
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()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_add"), IsAdminFilter())
async def handle_balance_add(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -505,9 +473,7 @@ async def handle_balance_add(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_balance_take"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_take"), IsAdminFilter())
async def handle_balance_take(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -524,9 +490,7 @@ async def handle_balance_take(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_balance_set"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_set"), IsAdminFilter())
async def handle_balance_set(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -574,9 +538,7 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Any
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter())
async def handle_key_edit(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback | AdminUserKeyEditorCallback,
@@ -628,15 +590,11 @@ async def handle_key_edit(
text += "</blockquote>"
if not update or not callback_data.edit:
await callback_query.message.edit_text(
text=text, reply_markup=build_key_edit_kb(key_details, email)
)
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
else:
await callback_query.message.edit_text(
text=text,
reply_markup=await build_users_key_expiry_kb(
session, callback_data.tg_id, email
),
reply_markup=await build_users_key_expiry_kb(session, callback_data.tg_id, email),
)
@@ -651,9 +609,7 @@ async def handle_back_to_key_menu(
tg_id = data["tg_id"]
await state.clear()
callback_data = AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
)
callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
await handle_key_edit(
callback_query=callback_query,
callback_data=callback_data,
@@ -662,9 +618,7 @@ async def handle_back_to_key_menu(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_renew"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_renew"), IsAdminFilter())
async def handle_user_choose_tariff_group(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -703,9 +657,7 @@ async def handle_user_choose_tariff(
await state.set_state(RenewTariffState.selecting_tariff)
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active == True)
.order_by(Tariff.id)
select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active is True).order_by(Tariff.id)
)
tariffs = result.scalars().all()
@@ -715,10 +667,7 @@ async def handle_user_choose_tariff(
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.button(
text=f"{tariff.name} {int(tariff.price_rub)}",
callback_data=f"confirm:{tariff.id}"
)
builder.button(text=f"{tariff.name} {int(tariff.price_rub)}", callback_data=f"confirm:{tariff.id}")
builder.button(text="🔙 Назад", callback_data="back:group")
builder.adjust(1)
@@ -739,18 +688,12 @@ async def handle_user_renew_confirm(
email = data["email"]
tg_id = data["tg_id"]
stmt = (
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(tariff_id=tariff_id)
)
stmt = update(Key).where(Key.tg_id == tg_id, Key.email == email).values(tariff_id=tariff_id)
await session.execute(stmt)
await session.commit()
await state.clear()
callback_data = AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
)
callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
await handle_key_edit(
callback_query=callback_query,
@@ -766,7 +709,7 @@ async def handle_back_to_group(
state: FSMContext,
session: AsyncSession,
):
data = await state.get_data()
await state.get_data()
result = await session.execute(select(Tariff.group_code).distinct())
groups = [row[0] for row in result.fetchall()]
@@ -784,9 +727,7 @@ async def handle_back_to_group(
await state.set_state(RenewTariffState.selecting_group)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), IsAdminFilter())
async def handle_change_expiry(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -795,14 +736,10 @@ async def handle_change_expiry(
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_reply_markup(
reply_markup=await build_users_key_expiry_kb(session, tg_id, email)
)
await callback_query.message.edit_reply_markup(reply_markup=await build_users_key_expiry_kb(session, tg_id, email))
@router.callback_query(
AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter()
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_expiry_add(
callback_query: CallbackQuery,
callback_data: AdminUserKeyEditorCallback,
@@ -823,9 +760,7 @@ async def handle_expiry_add(
return
if days:
await change_expiry_time(
key_details["expiry_time"] + days * 24 * 3600 * 1000, email, session
)
await change_expiry_time(key_details["expiry_time"] + days * 24 * 3600 * 1000, email, session)
await handle_key_edit(callback_query, callback_data, session, True)
return
@@ -838,9 +773,7 @@ async def handle_expiry_add(
)
@router.callback_query(
AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter()
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter())
async def handle_expiry_take(
callback_query: CallbackQuery,
callback_data: AdminUserKeyEditorCallback,
@@ -858,9 +791,7 @@ async def handle_expiry_take(
)
@router.callback_query(
AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter()
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter())
async def handle_expiry_set(
callback_query: CallbackQuery,
callback_data: AdminUserKeyEditorCallback,
@@ -888,9 +819,7 @@ async def handle_expiry_set(
f"\n\n📄 Текущая дата: {datetime.fromtimestamp(key_details['expiry_time'] / 1000).strftime('%Y-%m-%d %H:%M')}"
)
await callback_query.message.edit_text(
text=text, reply_markup=build_users_key_show_kb(tg_id, email)
)
await callback_query.message.edit_text(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
@@ -917,9 +846,7 @@ async def handle_expiry_time_input(message: Message, state: FSMContext, session:
return
try:
current_expiry_time = datetime.fromtimestamp(
key_details["expiry_time"] / 1000, tz=MOSCOW_TZ
)
current_expiry_time = datetime.fromtimestamp(key_details["expiry_time"] / 1000, tz=MOSCOW_TZ)
if op_type == "add":
days = int(message.text)
@@ -947,27 +874,19 @@ async def handle_expiry_time_input(message: Message, state: FSMContext, session:
await message.answer(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_update_key"), IsAdminFilter()
)
async def handle_update_key(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_update_key"), IsAdminFilter())
async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_text(
text=f"📡 Выберите кластер, на котором пересоздать ключ <b>{email}</b>:",
reply_markup=await build_cluster_selection_kb(
session, tg_id, email, action="confirm_admin_key_reissue"
),
reply_markup=await build_cluster_selection_kb(session, tg_id, email, action="confirm_admin_key_reissue"),
)
@router.callback_query(F.data.startswith("confirm_admin_key_reissue|"), IsAdminFilter())
async def confirm_admin_key_reissue(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Any, state: FSMContext):
_, tg_id, email, cluster_id = callback_query.data.split("|")
tg_id = int(tg_id)
@@ -980,16 +899,13 @@ async def confirm_admin_key_reissue(
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔗 Привязать тариф",
callback_data=AdminPanelCallback(action="clusters").pack()
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack()
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
)
await callback_query.message.edit_text(
@@ -998,7 +914,7 @@ async def confirm_admin_key_reissue(
f"🌐 <b>Кластер:</b> <code>{cluster_id}</code>\n"
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
f"💡 <b>Привяжите тариф к кластеру</b>",
reply_markup=builder.as_markup()
reply_markup=builder.as_markup(),
)
return
@@ -1011,20 +927,14 @@ async def confirm_admin_key_reissue(
text=country,
callback_data=f"admin_reissue_country|{tg_id}|{email}|{country}",
)
builder.row(
InlineKeyboardButton(
text="Назад", callback_data=f"users_key_edit|{email}"
)
)
builder.row(InlineKeyboardButton(text="Назад", callback_data=f"users_key_edit|{email}"))
await callback_query.message.edit_text(
"🌍 Выберите сервер (страну) для пересоздания подписки:",
reply_markup=builder.as_markup(),
)
return
result = await session.execute(
select(Key.remnawave_link).where(Key.email == email)
)
result = await session.execute(select(Key.remnawave_link).where(Key.email == email))
remnawave_link = result.scalar_one_or_none()
await update_subscription(tg_id, email, session, cluster_override=cluster_id, remnawave_link=remnawave_link)
@@ -1049,23 +959,20 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes
try:
data = await state.get_data()
cluster_id = data.get("cluster_id")
if cluster_id:
tariffs = await get_tariffs_for_cluster(session, cluster_id)
if not tariffs:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔗 Привязать тариф",
callback_data=AdminPanelCallback(action="clusters").pack()
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack()
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
)
await callback_query.message.edit_text(
@@ -1074,14 +981,12 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes
f"🌐 <b>Кластер:</b> <code>{cluster_id}</code>\n"
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
f"💡 <b>Привяжите тариф к кластеру</b>",
reply_markup=builder.as_markup()
reply_markup=builder.as_markup(),
)
return
result = await session.execute(
select(Key.remnawave_link, Key.tariff_id).where(Key.email == email)
)
remnawave_link, tariff_id = result.one_or_none() or (None, None)
result = await session.execute(select(Key.remnawave_link, Key.tariff_id).where(Key.email == email))
remnawave_link, _tariff_id = result.one_or_none() or (None, None)
await update_subscription(
tg_id=tg_id,
@@ -1102,12 +1007,8 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes
await callback_query.message.answer(f"❗ Ошибка: {e}")
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter()
)
async def handle_delete_key(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter())
async def handle_delete_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
email = callback_data.data
result = await session.execute(select(Key.client_id).where(Key.email == email))
@@ -1148,29 +1049,19 @@ async def handle_delete_key_confirm(
tasks = []
for cluster_name, cluster_servers in clusters.items():
for _ in cluster_servers:
tasks.append(
delete_key_from_cluster(cluster_name, email, client_id, session)
)
tasks.append(delete_key_from_cluster(cluster_name, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
await delete_key_from_servers()
await delete_key(session, client_id)
await callback_query.message.edit_text(
text="✅ Ключ успешно удален.", reply_markup=kb
)
await callback_query.message.edit_text(text="✅ Ключ успешно удален.", reply_markup=kb)
else:
await callback_query.message.edit_text(
text="🚫 Ключ не найден или уже удален.", reply_markup=kb
)
await callback_query.message.edit_text(text="🚫 Ключ не найден или уже удален.", reply_markup=kb)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_delete_user"), IsAdminFilter()
)
async def handle_delete_user(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback
):
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user"), IsAdminFilter())
async def handle_delete_user(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?",
@@ -1189,9 +1080,7 @@ async def handle_delete_user_confirm(
):
tg_id = callback_data.tg_id
result = await session.execute(
select(Key.email, Key.client_id).where(Key.tg_id == tg_id)
)
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():
@@ -1200,14 +1089,10 @@ async def handle_delete_user_confirm(
servers = await get_servers(session=session)
for email, client_id in key_records:
for cluster_id, _cluster in servers.items():
tasks.append(
delete_key_from_cluster(cluster_id, email, client_id, session)
)
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"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}"
)
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
@@ -1219,9 +1104,7 @@ async def handle_delete_user_confirm(
reply_markup=build_editor_kb(callback_data.tg_id),
)
except Exception as e:
logger.error(
f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}"
)
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
@@ -1236,9 +1119,7 @@ async def process_user_search(
) -> None:
await state.clear()
stmt_user = select(
User.username, User.balance, User.created_at, User.updated_at
).where(User.tg_id == tg_id)
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()
@@ -1254,19 +1135,11 @@ async def process_user_search(
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)
)
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)
)
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()
@@ -1285,10 +1158,7 @@ async def process_user_search(
stmt_ban = (
select(1)
.where(
(ManualBan.tg_id == tg_id)
& (or_(ManualBan.until.is_(None), ManualBan.until > func.now()))
)
.where((ManualBan.tg_id == tg_id) & (or_(ManualBan.until.is_(None), ManualBan.until > func.now())))
.limit(1)
)
result_ban = await session.execute(stmt_ban)
@@ -1319,14 +1189,12 @@ async def process_user_search(
await message.answer(text=text, reply_markup=kb)
async def change_expiry_time(
expiry_time: int, email: str, session: AsyncSession
) -> Exception | None:
async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession) -> Exception | None:
result = await session.execute(select(Key.client_id, Key.tariff_id, Key.server_id).where(Key.email == email))
row = result.first()
if not row:
return ValueError(f"User with email {email} was not found")
client_id, tariff_id, server_id = row
if server_id is None:
return ValueError(f"Key with client_id {client_id} was not found")
@@ -1335,8 +1203,7 @@ async def change_expiry_time(
device_limit = None
if tariff_id:
result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit)
.where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
select(Tariff.traffic_limit, Tariff.device_limit).where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
)
tariff = result.first()
if tariff:
@@ -1353,7 +1220,7 @@ async def change_expiry_time(
if any(s.get("server_name") == server_id for s in cluster_servers):
target_cluster = cluster_name
break
if not target_cluster:
return ValueError(f"No suitable cluster found for server {server_id}")
@@ -1367,14 +1234,12 @@ async def change_expiry_time(
hwid_device_limit=device_limit,
reset_traffic=False,
)
await update_key_expiry(session, client_id, expiry_time)
return None
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
async def handle_user_traffic(
callback_query: types.CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -1387,16 +1252,12 @@ async def handle_user_traffic(
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_text(
"⏳ Получаем данные о трафике, пожалуйста, подождите..."
)
await callback_query.message.edit_text("⏳ Получаем данные о трафике, пожалуйста, подождите...")
traffic_data = await get_user_traffic(session, tg_id, email)
if traffic_data["status"] == "error":
await callback_query.message.edit_text(
traffic_data["message"], reply_markup=build_editor_kb(tg_id, True)
)
await callback_query.message.edit_text(traffic_data["message"], reply_markup=build_editor_kb(tg_id, True))
return
total_traffic = 0
@@ -1412,14 +1273,10 @@ async def handle_user_traffic(
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
await callback_query.message.edit_text(
result_text, reply_markup=build_editor_kb(tg_id, True)
)
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
@router.callback_query(
AdminPanelCallback.filter(F.action == "restore_trials"), IsAdminFilter()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_trials"), IsAdminFilter())
async def confirm_restore_trials(callback_query: types.CallbackQuery):
"""
Меню подтверждения перед восстановлением пробников.
@@ -1438,21 +1295,10 @@ async def confirm_restore_trials(callback_query: types.CallbackQuery):
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter())
async def restore_trials(callback_query: types.CallbackQuery, session: AsyncSession):
active_keys_subq = (
select(Key.tg_id)
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
.subquery()
)
stmt = (
update(User)
.where(~User.tg_id.in_(select(active_keys_subq.c.tg_id)))
.where(User.trial != 0)
.values(trial=0)
)
active_keys_subq = select(Key.tg_id).where(Key.expiry_time > func.extract("epoch", func.now()) * 1000).subquery()
stmt = update(User).where(~User.tg_id.in_(select(active_keys_subq.c.tg_id))).where(User.trial != 0).values(trial=0)
await session.execute(stmt)
await session.commit()
@@ -1494,9 +1340,7 @@ async def handle_users_export_referrals(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
async def handle_create_key_start(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -1557,18 +1401,14 @@ async def handle_create_key_start(
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
async def handle_create_key_country(
callback_query: CallbackQuery, state: FSMContext, session
):
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext, session):
country = callback_query.data
await state.update_data(country=country)
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
result = await session.execute(
select(Server.cluster_name).where(Server.server_name == country)
)
result = await session.execute(select(Server.cluster_name).where(Server.server_name == country))
row = result.mappings().first()
if not row:
@@ -1583,10 +1423,7 @@ async def handle_create_key_country(
for tariff in tariffs:
if tariff["duration_days"] < 1:
continue
builder.button(
text=f"{tariff['name']}{tariff['price_rub']}",
callback_data=f"tariff_{tariff['id']}"
)
builder.button(text=f"{tariff['name']}{tariff['price_rub']}", callback_data=f"tariff_{tariff['id']}")
builder.adjust(1)
builder.row(build_admin_back_btn())
@@ -1598,9 +1435,7 @@ async def handle_create_key_country(
@router.callback_query(UserEditorState.selecting_cluster, IsAdminFilter())
async def handle_create_key_cluster(
callback_query: CallbackQuery, state: FSMContext, session
):
async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMContext, session):
cluster_name = callback_query.data
data = await state.get_data()
@@ -1619,10 +1454,7 @@ async def handle_create_key_cluster(
for tariff in tariffs:
if tariff["duration_days"] < 1:
continue
builder.button(
text=f"{tariff['name']}{tariff['price_rub']}",
callback_data=f"tariff_{tariff['id']}"
)
builder.button(text=f"{tariff['name']}{tariff['price_rub']}", callback_data=f"tariff_{tariff['id']}")
builder.adjust(1)
builder.row(build_admin_back_btn())
@@ -1634,9 +1466,7 @@ async def handle_create_key_cluster(
@router.callback_query(UserEditorState.selecting_duration, IsAdminFilter())
async def handle_create_key_duration(
callback_query: CallbackQuery, state: FSMContext, session
):
async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMContext, session):
data = await state.get_data()
tg_id = data.get("tg_id", callback_query.from_user.id)
@@ -1692,9 +1522,7 @@ async def handle_create_key_duration(
)
else:
await callback_query.message.edit_text(
"❌ Не удалось определить источник — страна или кластер."
)
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
except Exception as e:
logger.error(f"[CreateKey] Ошибка при создании ключа: {e}")
@@ -1704,9 +1532,7 @@ async def handle_create_key_duration(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter())
async def handle_reset_traffic(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
@@ -1715,16 +1541,12 @@ async def handle_reset_traffic(
tg_id = callback_data.tg_id
email = callback_data.data
stmt = select(Key.server_id, Key.client_id).where(
(Key.tg_id == tg_id) & (Key.email == email)
)
stmt = select(Key.server_id, Key.client_id).where((Key.tg_id == tg_id) & (Key.email == email))
result = await session.execute(stmt)
record = result.first()
if not record:
await callback_query.message.edit_text(
"❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id)
)
await callback_query.message.edit_text("❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id))
return
cluster_id, _client_id = record
@@ -1743,12 +1565,8 @@ async def handle_reset_traffic(
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_ban"), IsAdminFilter()
)
async def handle_user_ban(
callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext
):
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_ban"), IsAdminFilter())
async def handle_user_ban(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)
@@ -1779,9 +1597,7 @@ async def handle_ban_reason_input(message: Message, state: FSMContext):
@router.message(BanUserStates.waiting_for_ban_duration, IsAdminFilter())
async def handle_ban_duration_input(
message: Message, state: FSMContext, session: AsyncSession
):
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")
@@ -1832,29 +1648,23 @@ async def handle_ban_duration_input(
await state.clear()
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_unban"), IsAdminFilter()
)
@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.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)
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))
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_editor"), IsAdminFilter()
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_editor"), IsAdminFilter())
async def handle_users_editor(
callback: CallbackQuery,
callback_data: AdminUserEditorCallback,
+5 -9
View File
@@ -1,5 +1,6 @@
import random
import secrets
from typing import Any
from aiogram import F, Router
@@ -12,14 +13,13 @@ from logger import logger
from .utils import edit_or_send_message
router = Router()
async def generate_captcha(message: Message, state: FSMContext):
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
wrong_emojis = random.sample(
[e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3
)
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
@@ -29,9 +29,7 @@ async def generate_captcha(message: Message, state: FSMContext):
if "user_data" not in state_data:
from_user = message.from_user
if not from_user:
logger.warning(
"[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data"
)
logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
return None
await state.update_data(
@@ -69,9 +67,7 @@ async def generate_captcha(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(
callback: CallbackQuery, state: FSMContext, session: Any, admin: bool
):
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import process_start_logic
selected_emoji = callback.data.split("captcha_")[1]
+22 -45
View File
@@ -1,8 +1,10 @@
import html
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -13,27 +15,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import ADMIN_ID
from database import (
add_payment,
add_user,
check_coupon_usage,
check_user_exists,
create_coupon_usage,
get_coupon_by_code,
get_keys,
get_tariff_by_id,
update_balance,
update_coupon_usage_count,
update_key_expiry,
add_payment,
get_tariff_by_id,
)
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPONS_DAYS_MESSAGE,
COUPON_ALREADY_USED_MSG,
COUPON_DAYS_ACTIVATED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
COUPONS_DAYS_MESSAGE,
COUPON_DAYS_ACTIVATED_MSG
)
from handlers.utils import edit_or_send_message, format_days
from logger import logger
@@ -49,9 +51,7 @@ router = Router()
@router.callback_query(F.data == "activate_coupon")
@router.message(F.text == "/activate_coupon")
async def handle_activate_coupon(
callback_query_or_message: Message | CallbackQuery, state: FSMContext
):
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -88,9 +88,7 @@ async def activate_coupon(
if not coupon:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
@@ -128,15 +126,8 @@ async def activate_coupon(
await update_balance(session, user_id, coupon.amount)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, user_id)
await add_payment(
session,
tg_id=user_id,
amount=coupon.amount,
payment_system="coupon"
)
await message.answer(
f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей."
)
await add_payment(session, tg_id=user_id, amount=coupon.amount, payment_system="coupon")
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей.")
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
@@ -160,9 +151,9 @@ async def activate_coupon(
for key in active_keys:
key_display = html.escape((key.alias or key.email).strip())
expiry_date = datetime.fromtimestamp(
key.expiry_time / 1000, tz=moscow_tz
).strftime("до %d.%m.%y, %H:%M")
expiry_date = datetime.fromtimestamp(key.expiry_time / 1000, tz=moscow_tz).strftime(
"до %d.%m.%y, %H:%M"
)
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(
text=key_display,
@@ -204,9 +195,7 @@ async def handle_key_extension(
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
coupon = result.scalar_one_or_none()
if not coupon or coupon.usage_count >= coupon.usage_limit:
await callback_query.message.edit_text(
"❌ Купон недействителен или лимит исчерпан."
)
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
@@ -216,14 +205,10 @@ async def handle_key_extension(
await state.clear()
return
result = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
key = result.scalar_one_or_none()
if not key or key.is_frozen:
await callback_query.message.edit_text(
"❌ Выбранная подписка не найдена или заморожена."
)
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
@@ -252,19 +237,13 @@ async def handle_key_extension(
await create_coupon_usage(session, coupon.id, tg_id)
alias = key.alias or key.email
expiry_date = datetime.fromtimestamp(
new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")
).strftime("%d.%m.%y, %H:%M")
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
"%d.%m.%y, %H:%M"
)
await callback_query.message.answer(
COUPON_DAYS_ACTIVATED_MSG.format(
alias=alias,
days=format_days(coupon.days),
expiry=expiry_date
)
)
await process_callback_view_profile(
callback_query.message, state, admin, session
COUPON_DAYS_ACTIVATED_MSG.format(alias=alias, days=format_days(coupon.days), expiry=expiry_date)
)
await process_callback_view_profile(callback_query.message, state, admin, session)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
@@ -292,6 +271,4 @@ async def handle_exit_coupon_input(
):
await state.clear()
is_admin = callback_query.from_user.id in ADMIN_ID
await process_callback_view_profile(
callback_query.message, state, admin=is_admin, session=session
)
await process_callback_view_profile(callback_query.message, state, admin=is_admin, session=session)
+2 -8
View File
@@ -31,11 +31,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
)
)
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
builder.row(
InlineKeyboardButton(
text="💰 Ввести сумму доната",
@@ -77,9 +73,7 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
if message.text.isdigit():
amount = int(message.text)
if amount // RUB_TO_XTR <= 0:
await message.answer(
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
)
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
return
await state.update_data(amount=amount)
+1
View File
@@ -6,6 +6,7 @@ from config import SUPPORT_CHAT_URL
from handlers.buttons import MAIN_MENU, SUPPORT
from handlers.texts import FALLBACK_MESSAGE
fallback_router = Router()
+1
View File
@@ -4,6 +4,7 @@ from aiogram import Router
from .instructions import router as instructions_router
router = Router(name="instructions_main_router")
router.include_routers(
+9 -17
View File
@@ -1,4 +1,5 @@
import os
from typing import Any
from aiogram import F, Router
@@ -29,14 +30,15 @@ from handlers.buttons import (
from handlers.texts import (
CHOOSE_DEVICE_TEXT,
CONNECT_TV_TEXT,
INSTRUCTIONS,
INSTRUCTION_MACOS,
INSTRUCTION_PC,
INSTRUCTIONS,
KEY_MESSAGE,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
router = Router()
@@ -79,12 +81,8 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
return
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}")
)
builder.row(
InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}")
)
builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
@@ -105,9 +103,7 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
builder.row(
InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}")
)
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -129,9 +125,7 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
builder.row(
InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}")
)
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -148,9 +142,7 @@ async def process_connect_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")
)
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -179,4 +171,4 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any):
text=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
)
+1
View File
@@ -9,6 +9,7 @@ from .key_renew import router as renew_router
from .key_view import router as view_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
+20 -53
View File
@@ -1,7 +1,9 @@
import os
from io import BytesIO
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -38,6 +40,7 @@ from handlers.texts import (
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@@ -47,24 +50,12 @@ async def handle_connect_device(callback_query: CallbackQuery):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}")
)
builder.row(
InlineKeyboardButton(
text=ANDROID, callback_data=f"connect_android|{key_name}"
)
)
builder.row(
InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}")
)
builder.row(
InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}")
)
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -78,9 +69,7 @@ async def handle_connect_device(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(
callback_query: CallbackQuery, session: AsyncSession
):
async def process_callback_connect_phone(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
@@ -110,9 +99,7 @@ async def process_callback_connect_phone(
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
@@ -124,9 +111,7 @@ async def process_callback_connect_phone(
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(
callback_query: CallbackQuery, session: AsyncSession
):
async def process_callback_connect_ios(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
@@ -148,12 +133,8 @@ async def process_callback_connect_ios(
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -165,9 +146,7 @@ async def process_callback_connect_ios(
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(
callback_query: CallbackQuery, session: AsyncSession
):
async def process_callback_connect_android(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
@@ -186,19 +165,11 @@ async def process_callback_connect_android(
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID)
)
builder.row(
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}")
)
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -224,9 +195,7 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSessio
qr_data = record.key or record.remnawave_link
if not qr_data:
await callback_query.message.answer(
"❌ У этой подписки отсутствует ссылка для подключения."
)
await callback_query.message.answer("❌ У этой подписки отсутствует ссылка для подключения.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
@@ -243,9 +212,7 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSessio
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
+18 -39
View File
@@ -1,4 +1,5 @@
import time
from typing import Any
from aiogram import F, Router
@@ -25,13 +26,12 @@ from handlers.texts import (
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(
callback_query: CallbackQuery, session: Any
):
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
@@ -55,9 +55,7 @@ async def process_callback_unfreeze_subscription(
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
@@ -74,9 +72,7 @@ async def process_callback_unfreeze_subscription_confirm(
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=True, session=session
)
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session)
if result["status"] != "success":
logger.warning(f"Не удалось включить подписку: {result.get('error') or result.get('results')}")
@@ -86,20 +82,14 @@ async def process_callback_unfreeze_subscription_confirm(
if not cluster_servers:
text_error = "Сервер не найден."
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
callback_query.message, text_error, builder.as_markup()
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(callback_query.message, text_error, builder.as_markup())
return
tariff = await get_tariff_by_id(session, record["tariff_id"]) if record.get("tariff_id") else None
if not tariff:
logger.info(
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
)
logger.info("[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения.")
total_gb = TRIAL_CONFIG["traffic_limit_gb"]
hwid_limit = TRIAL_CONFIG["hwid_limit"]
else:
@@ -116,7 +106,7 @@ async def process_callback_unfreeze_subscription_confirm(
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
await session.commit()
added_days = max(leftover / (1000 * 86400), 0.01)
max(leftover / (1000 * 86400), 0.01)
logger.info(
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
)
@@ -134,9 +124,7 @@ async def process_callback_unfreeze_subscription_confirm(
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(callback_query.message, text_ok, builder.as_markup())
except Exception as e:
@@ -144,9 +132,7 @@ async def process_callback_unfreeze_subscription_confirm(
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(
callback_query: CallbackQuery, session: Any
):
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
@@ -174,9 +160,7 @@ async def process_callback_freeze_subscription(
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
@@ -193,9 +177,7 @@ async def process_callback_freeze_subscription_confirm(
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=False, session=session
)
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
@@ -208,9 +190,7 @@ async def process_callback_freeze_subscription_confirm(
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
@@ -218,11 +198,11 @@ async def process_callback_freeze_subscription_confirm(
)
else:
text_error = f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
@@ -231,4 +211,3 @@ async def process_callback_freeze_subscription_confirm(
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
+1
View File
@@ -7,6 +7,7 @@ from .key_country_mode import router as country_router
from .key_create import router as create_router
from .key_discount_mode import router as discount_router
router = Router(name="key_mode_router")
router.include_routers(create_router, cluster_router, country_router, discount_router)
+17 -33
View File
@@ -1,7 +1,9 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import (
CallbackQuery,
@@ -34,14 +36,15 @@ from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
format_days,
format_months,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -97,7 +100,7 @@ async def key_cluster_mode(
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
error_message = str(e)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
@@ -121,9 +124,7 @@ async def key_cluster_mode(
is_trial=is_trial,
)
logger.info(
f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}"
)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
key_record = await get_key_details(session, email)
if not key_record:
@@ -146,9 +147,7 @@ async def key_cluster_mode(
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = (
"❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
)
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if safe_to_edit:
await edit_or_send_message(
@@ -162,42 +161,27 @@ async def key_cluster_mode(
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
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=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=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"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
expiry_time_local - datetime.now(moscow_tz)
tariff_info = None
if plan:
tariff_info = await get_tariff_by_id(session, plan)
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
@@ -209,18 +193,18 @@ async def key_cluster_mode(
final_link,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0)
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
)
else:
tariff_duration = tariff_info["name"]
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title
subgroup_title=subgroup_title,
)
default_media_path = "img/pic.jpg"
+34 -89
View File
@@ -1,9 +1,11 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
@@ -34,30 +36,22 @@ from database import (
update_trial,
)
from database.models import Key, Server, Tariff
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
MY_SUB
)
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, MY_SUB, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
format_days,
format_months,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -91,9 +85,7 @@ async def key_country_mode(
logger.error(f"Нет доступных кластеров: {e}")
text = str(e)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
@@ -113,18 +105,13 @@ async def key_country_mode(
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
available_servers = []
tasks = [
asyncio.create_task(check_server_availability(server, session))
for server in servers
]
tasks = [asyncio.create_task(check_server_availability(server, session)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
@@ -132,21 +119,15 @@ async def key_country_mode(
available_servers.append(server["server_name"])
if not available_servers:
logger.warning(
f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}"
)
logger.warning(f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
logger.info(
f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}"
)
logger.info(f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
@@ -194,9 +175,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
await callback_query.answer(
"❌ Кластер для текущего сервера не найден", show_alert=True
)
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
@@ -219,9 +198,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
.all()
)
if not servers:
await callback_query.answer(
"❌ Доступных серверов в кластере не найдено", show_alert=True
)
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
@@ -245,9 +222,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer(
"❌ Нет доступных серверов для смены локации", show_alert=True
)
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
@@ -256,9 +231,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -267,18 +240,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
media_path=None,
)
except Exception as e:
logger.error(
f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}"
)
await callback_query.answer(
"❌ Ошибка смены локации. Попробуйте снова.", show_alert=True
)
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
@@ -294,9 +261,7 @@ async def handle_country_selection(
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer(
"❌ Некорректное время истечения. Попробуйте снова."
)
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
@@ -354,7 +319,7 @@ async def finalize_key_creation(
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
tariff_id = old_key_details.get("tariff_id") or tariff_id
tariff_id = old_key_details.get("tariff_id") or tariff_id
else:
while True:
key_name = generate_random_email()
@@ -388,9 +353,7 @@ async def finalize_key_creation(
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
try:
result = await session.execute(
select(Server).where(Server.server_name == selected_country)
)
result = await session.execute(select(Server).where(Server.server_name == selected_country))
server_info = result.scalar_one_or_none()
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден")
@@ -413,9 +376,7 @@ async def finalize_key_creation(
xui = await get_xui_instance(old_server_info.api_url)
await delete_client(xui, old_server_info.inbound_id, email, client_id)
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(key=None)
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(key=None)
)
elif old_server_info.panel_type.lower() == "remnawave":
remna = RemnawaveAPI(old_server_info.api_url)
@@ -529,21 +490,15 @@ async def finalize_key_creation(
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
elif CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}")
)
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=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))
@@ -555,7 +510,7 @@ async def finalize_key_creation(
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff_info = result.scalar_one_or_none()
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
@@ -567,18 +522,18 @@ async def finalize_key_creation(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0)
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
)
else:
tariff_duration = tariff_info["name"] if tariff_info else None
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title
subgroup_title=subgroup_title,
)
await edit_or_send_message(
@@ -604,31 +559,21 @@ async def check_server_availability(server_info: dict, session: AsyncSession) ->
try:
if max_keys is not None:
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.server_id == server_name)
)
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == server_name))
key_count = result.scalar()
if key_count >= max_keys:
logger.info(
f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}."
)
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
return False
except SQLAlchemyError as e:
logger.warning(
f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}"
)
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
return False
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(
remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0
)
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
+26 -52
View File
@@ -1,15 +1,16 @@
from datetime import datetime, timedelta
from typing import Any
from collections import defaultdict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from math import ceil
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from math import ceil
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
NOTIFY_EXTRA_DAYS,
@@ -46,6 +47,7 @@ from logger import logger
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -58,16 +60,14 @@ class Form(FSMContext):
@router.callback_query(F.data == "create_key")
@router.callback_query(F.data == "buy")
@router.message(F.text == "/buy")
async def confirm_create_new_key(
callback_query_or_message: CallbackQuery | Message, state: FSMContext, session: Any
):
async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Message, state: FSMContext, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
tg_id = callback_query_or_message.message.chat.id
message_or_query = callback_query_or_message
else:
tg_id = callback_query_or_message.chat.id
message_or_query = callback_query_or_message
await handle_key_creation(tg_id, state, session, message_or_query)
@@ -91,9 +91,7 @@ async def handle_key_creation(
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=CREATING_CONNECTION_MSG,
reply_markup=None,
@@ -109,14 +107,12 @@ async def handle_key_creation(
logger.error(f"Нет доступных кластеров: {e}")
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=str(e),
reply_markup=InlineKeyboardBuilder().row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
).as_markup(),
reply_markup=InlineKeyboardBuilder()
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
.as_markup(),
)
return
@@ -125,17 +121,16 @@ async def handle_key_creation(
if not tariffs:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
is_admin = result.scalar_one_or_none() is not None
if is_admin:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔗 Привязать тариф",
callback_data=AdminPanelCallback(action="clusters").pack()
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = (
f"🚫 <b>Невозможно создать подписку</b>\n\n"
f"📊 <b>Информация о кластере:</b>\n<blockquote>"
@@ -147,12 +142,10 @@ async def handle_key_creation(
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = "❌ Нет доступных тарифов для выбора."
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=text,
reply_markup=builder.as_markup(),
@@ -163,9 +156,7 @@ async def handle_key_creation(
if not group_code:
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text="❌ Не удалось определить группу тарифов.",
reply_markup=None,
@@ -198,11 +189,7 @@ async def handle_key_creation(
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
target_message = (
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
)
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
await edit_or_send_message(
target_message=target_message,
@@ -249,12 +236,7 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
callback_data=f"select_tariff_plan|{t['id']}",
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data="back_to_tariff_group_list"
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -266,7 +248,7 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
@router.callback_query(F.data == "back_to_tariff_group_list")
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any):
data = await state.get_data()
await state.get_data()
tg_id = callback.from_user.id
await handle_key_creation(
tg_id=tg_id,
@@ -277,9 +259,7 @@ async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext,
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.from_user.id
tariff_id = int(callback_query.data.split("|")[1])
@@ -327,9 +307,7 @@ async def select_tariff_plan(
return
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")
)
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
@@ -351,11 +329,7 @@ async def create_key(
plan: int = None,
):
if not await check_user_exists(session, tg_id):
from_user = (
message_or_query.from_user
if isinstance(message_or_query, CallbackQuery | Message)
else None
)
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
if from_user:
await add_user(
tg_id=from_user.id,
@@ -14,6 +14,7 @@ from logger import logger
from .key_create import select_tariff_plan
router = Router()
+36 -83
View File
@@ -1,19 +1,21 @@
from datetime import datetime, timedelta
from typing import Any
from aiogram.fsm.context import FSMContext
from collections import defaultdict
from datetime import datetime, timedelta
from math import ceil
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from math import ceil
import pytz
from bot import bot
from config import USE_NEW_PAYMENT_FLOW
from database import (
check_tariff_exists,
create_temporary_data,
get_balance,
get_key_by_server,
@@ -22,11 +24,10 @@ from database import (
get_tariffs,
update_balance,
update_key_expiry,
check_tariff_exists,
)
from database.models import Server, Key
from database.models import Key, Server
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.buttons import BACK, MAIN_MENU, PAYMENT
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
@@ -38,10 +39,10 @@ from handlers.texts import (
PLAN_SELECTION_MSG,
get_renewal_message,
)
from handlers.buttons import MY_SUB
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -62,10 +63,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
server_id = record["server_id"]
tariff_id = record.get("tariff_id")
await state.update_data(
renew_key_name=key_name,
renew_client_id=client_id
)
await state.update_data(renew_key_name=key_name, renew_client_id=client_id)
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
@@ -82,9 +80,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
Server.cluster_name == server_id,
)
row = await session.execute(
select(Server.tariff_group).where(filter_condition).limit(1)
)
row = await session.execute(select(Server.tariff_group).where(filter_condition).limit(1))
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
@@ -134,9 +130,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
),
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
@@ -182,9 +176,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
Server.cluster_name == server_id,
)
row = await session.execute(
select(Server.tariff_group).where(filter_condition).limit(1)
)
row = await session.execute(select(Server.tariff_group).where(filter_condition).limit(1))
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW_SUBGROUP] Тарифная группа не найдена для server_id={server_id}")
@@ -197,7 +189,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
if not subgroup:
await callback.message.answer("❌ Подгруппа не найдена.")
return
tariffs = await get_tariffs(session, group_code=group_code)
filtered = [t for t in tariffs if t["subgroup_title"] == subgroup and t["is_active"]]
@@ -218,12 +210,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data="renew_menu"
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="renew_menu"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -272,13 +259,9 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(
current_time + timedelta(days=duration_days).total_seconds() * 1000
)
new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000)
else:
new_expiry_time = int(
expiry_time + timedelta(days=duration_days).total_seconds() * 1000
)
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000)
balance = round(await get_balance(session, tg_id), 2)
cost = round(cost, 2)
@@ -312,21 +295,15 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(
required_amount=required_amount
),
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
)
return
logger.info(
f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней"
)
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
await complete_key_renewal(
session,
tg_id,
@@ -340,26 +317,16 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
)
except Exception as e:
logger.error(
f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}"
)
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def resolve_cluster_name(
session: AsyncSession, server_or_cluster: str
) -> str | None:
result = await session.execute(
select(Server).where(Server.cluster_name == server_or_cluster).limit(1)
)
async def resolve_cluster_name(session: AsyncSession, server_or_cluster: str) -> str | None:
result = await session.execute(select(Server).where(Server.cluster_name == server_or_cluster).limit(1))
server = result.scalars().first()
if server:
return server_or_cluster
result = await session.execute(
select(Server.cluster_name)
.where(Server.server_name == server_or_cluster)
.limit(1)
)
result = await session.execute(select(Server.cluster_name).where(Server.server_name == server_or_cluster).limit(1))
row = result.scalar()
return row
@@ -376,9 +343,7 @@ async def complete_key_renewal(
tariff_id: int,
):
try:
logger.info(
f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)"
)
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
@@ -387,14 +352,12 @@ async def complete_key_renewal(
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
formatted_expiry_date = datetime.fromtimestamp(
new_expiry_time / 1000, tz=moscow_tz
).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
)
response_message = get_renewal_message(
@@ -402,7 +365,7 @@ async def complete_key_renewal(
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date,
subgroup_title=tariff.get("subgroup_title", "")
subgroup_title=tariff.get("subgroup_title", ""),
)
if callback_query:
@@ -414,13 +377,9 @@ async def complete_key_renewal(
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(
tg_id, response_message, reply_markup=builder.as_markup()
)
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
key_info = await get_key_details(session, email)
if not key_info:
@@ -445,16 +404,10 @@ async def complete_key_renewal(
)
await update_key_expiry(session, client_id, new_expiry_time)
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(tariff_id=tariff_id)
)
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await update_balance(session, tg_id, -cost)
logger.info(
f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})"
)
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
+73 -198
View File
@@ -1,4 +1,5 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
@@ -50,44 +51,34 @@ async def create_key_on_cluster(
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(
f"Кластер или сервер с ID/именем {cluster_id} не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(
f"[Key Creation] Нет доступных серверов в кластере {cluster_id}"
)
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
if plan is not None and traffic_limit_bytes is None:
tariff = await get_tariff_by_id(session, plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
traffic_limit_bytes = (
int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
)
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
if hwid_limit is None and tariff.get("device_limit") is not None:
hwid_limit = int(tariff["device_limit"])
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave"
and await check_server_key_limit(s, session)
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, session)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui"
and await check_server_key_limit(s, session)
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, session)
]
if not remnawave_servers and not xui_servers:
logger.warning(
f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}"
)
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
@@ -101,14 +92,8 @@ async def create_key_on_cluster(
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = (
datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
)
inbound_ids = [
s.get("inbound_id")
for s in remnawave_servers
if s.get("inbound_id")
]
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
@@ -141,15 +126,11 @@ async def create_key_on_cluster(
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(
f"[Key Creation] Пользователь создан в Remnawave: {result}"
)
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
logger.info(
f"[Debug] 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}"
)
logger.info(f"[Debug] 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}")
if xui_servers:
if SUPERNODE:
@@ -303,25 +284,17 @@ async def renew_key_in_cluster(
if found_servers:
cluster = found_servers
else:
raise ValueError(
f"Кластер или сервер с ID/именем {cluster_id} не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
result = await session.execute(
select(Key.tg_id, Key.server_id).where(Key.client_id == client_id).limit(1)
)
result = await session.execute(select(Key.tg_id, Key.server_id).where(Key.client_id == client_id).limit(1))
row = result.first()
if not row:
logger.error(
f"Не найден пользователь с client_id={client_id} в таблице keys."
)
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
return False
tg_id, server_id = row
result = await session.execute(
select(Server.tariff_group).where(Server.server_name == server_id)
)
result = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
tariff_group_row = result.scalar_one_or_none()
if tariff_group_row:
@@ -348,18 +321,14 @@ async def renew_key_in_cluster(
(
s
for s in cluster
if s.get("panel_type", "").lower() == "remnawave"
and s.get("inbound_id") in remnawave_inbound_ids
if s.get("panel_type", "").lower() == "remnawave" and s.get("inbound_id") in remnawave_inbound_ids
),
None,
)
if remnawave_server:
remna = RemnawaveAPI(remnawave_server["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
expire_iso = (
datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat()
+ "Z"
)
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
updated = await remna.update_user(
uuid=client_id,
@@ -379,8 +348,8 @@ async def renew_key_in_cluster(
)
row = result.one_or_none()
remnawave_link = row[0] if row else None
old_key = row[1] if row else None
row[1] if row else None
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
@@ -400,14 +369,11 @@ async def renew_key_in_cluster(
new_client_id = result.get("uuid")
new_remnawave_link = result.get("subscriptionUrl")
logger.info(f"Пользователь Remnawave {client_id} успешно создан")
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(
client_id=new_client_id,
remnawave_link=new_remnawave_link
)
.values(client_id=new_client_id, remnawave_link=new_remnawave_link)
)
await session.commit()
else:
@@ -424,9 +390,7 @@ async def renew_key_in_cluster(
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск."
)
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
@@ -450,7 +414,7 @@ async def renew_key_in_cluster(
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
if not updated:
logger.warning(f"Не удалось обновить клиента {unique_email}, пробуем создать")
config = ClientConfig(
@@ -478,15 +442,11 @@ async def renew_key_in_cluster(
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
except Exception as e:
logger.error(
f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}"
)
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
async def delete_key_from_cluster(
cluster_id: str, email: str, client_id: str, session: AsyncSession
):
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
try:
servers = await get_servers(session)
@@ -502,9 +462,7 @@ async def delete_key_from_cluster(
if found_servers:
cluster = found_servers
else:
raise ValueError(
f"Кластер или сервер с ID/именем {cluster_id} не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
@@ -513,29 +471,21 @@ async def delete_key_from_cluster(
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(
f"[Remnawave] Не удалось войти на сервер {server_name}"
)
logger.error(f"[Remnawave] Не удалось войти на сервер {server_name}")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(
f"[Remnawave] Клиент {client_id} успешно удалён с {server_name}"
)
logger.info(f"[Remnawave] Клиент {client_id} успешно удалён с {server_name}")
else:
logger.warning(
f"[Remnawave] Не удалось удалить клиента {client_id} с {server_name}"
)
logger.warning(f"[Remnawave] Не удалось удалить клиента {client_id} с {server_name}")
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"[3x-ui] INBOUND_ID отсутствует на сервере {server_name}. Пропуск."
)
logger.warning(f"[3x-ui] INBOUND_ID отсутствует на сервере {server_name}. Пропуск.")
continue
await delete_client(
@@ -544,19 +494,13 @@ async def delete_key_from_cluster(
email=email,
client_id=client_id,
)
logger.info(
f"[3x-ui] Клиент {client_id} удалён с сервера {server_name}"
)
logger.info(f"[3x-ui] Клиент {client_id} удалён с сервера {server_name}")
else:
logger.warning(
f"[Unknown] Неизвестный тип панели '{panel_type}' для сервера {server_name}"
)
logger.warning(f"[Unknown] Неизвестный тип панели '{panel_type}' для сервера {server_name}")
except Exception as e:
logger.error(
f"❌ Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}"
)
logger.error(f"❌ Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}")
raise
@@ -588,30 +532,18 @@ async def update_key_on_cluster(
if found_servers:
cluster = found_servers
else:
raise ValueError(
f"Кластер или сервер с ID/именем {cluster_id} не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
expire_iso = (
datetime.utcfromtimestamp(expiry_time / 1000)
.replace(tzinfo=timezone.utc)
.isoformat()
)
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
remnawave_servers = [
s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"
]
xui_servers = [
s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"
]
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [
s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")
]
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
@@ -626,7 +558,7 @@ async def update_key_on_cluster(
.order_by(Tariff.duration_days.desc())
.limit(1)
)
tariff = result.scalar_one_or_none()
result.scalar_one_or_none()
short_uuid = None
if remnawave_link and "/" in remnawave_link:
@@ -641,7 +573,7 @@ async def update_key_on_cluster(
"activeUserInbounds": inbound_ids,
}
if traffic_limit is not None:
user_data["trafficLimitBytes"] = traffic_limit * 1024 ** 3
user_data["trafficLimitBytes"] = traffic_limit * 1024**3
if device_limit is not None:
user_data["hwidDeviceLimit"] = device_limit
if short_uuid:
@@ -652,18 +584,14 @@ async def update_key_on_cluster(
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(
f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}"
)
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[Update] Ошибка создания Remnawave клиента")
else:
logger.error("[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(
f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}"
)
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
tasks = []
@@ -672,9 +600,7 @@ async def update_key_on_cluster(
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск."
)
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(server_info["api_url"])
@@ -692,9 +618,9 @@ async def update_key_on_cluster(
.order_by(Tariff.duration_days.desc())
.limit(1)
)
tariff = result.scalar_one_or_none()
result.scalar_one_or_none()
total_gb_bytes = int(traffic_limit * 1024 ** 3) if traffic_limit else 0
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit else 0
device_limit_value = device_limit if device_limit is not None else None
config = ClientConfig(
@@ -715,15 +641,11 @@ async def update_key_on_cluster(
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(
f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}"
)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(
f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}"
)
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise
@@ -735,9 +657,7 @@ async def update_subscription(
country_override: str = None,
remnawave_link: str = None,
) -> None:
result = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.email == email)
)
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
record = result.scalar_one_or_none()
if not record:
@@ -753,9 +673,7 @@ async def update_subscription(
traffic_limit = None
device_limit = None
if tariff_id:
result = await session.execute(
select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
)
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
@@ -763,7 +681,7 @@ async def update_subscription(
else:
logger.warning(f"[LOG] update_subscription: тариф с id={tariff_id} не найден!")
else:
logger.warning(f"[LOG] update_subscription: tariff_id отсутствует!")
logger.warning("[LOG] update_subscription: tariff_id отсутствует!")
await delete_key_from_cluster(old_cluster_id, email, client_id, session=session)
await session.execute(delete(Key).where(Key.tg_id == tg_id, Key.email == email))
@@ -787,7 +705,7 @@ async def update_subscription(
session=session,
traffic_limit=traffic_limit,
device_limit=device_limit,
remnawave_link=remnawave_link
remnawave_link=remnawave_link,
)
servers = await get_servers(session)
@@ -820,18 +738,12 @@ async def update_subscription(
)
async def get_user_traffic(
session: AsyncSession, tg_id: int, email: str
) -> dict[str, Any]:
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
result = await session.execute(
select(Key.client_id, Key.server_id).where(
Key.tg_id == tg_id, Key.email == email
)
)
result = await session.execute(select(Key.client_id, Key.server_id).where(Key.tg_id == tg_id, Key.email == email))
rows = result.all()
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
@@ -840,8 +752,7 @@ async def get_user_traffic(
result = await session.execute(
select(Server).where(
(Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids)),
Server.enabled == True
(Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids)), Server.enabled is True
)
)
server_rows = result.scalars().all()
@@ -894,9 +805,7 @@ async def get_user_traffic(
server_id = row.server_id
matched_servers = [
s
for s in servers_map.values()
if s["server_name"] == server_id or s["cluster_name"] == server_id
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
@@ -940,9 +849,7 @@ async def toggle_client_on_cluster(
) -> dict[str, Any]:
try:
if session is None:
raise ValueError(
"[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster"
)
raise ValueError("[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster")
servers = await get_servers(session)
cluster = servers.get(cluster_id)
@@ -955,9 +862,7 @@ async def toggle_client_on_cluster(
if found_servers:
cluster = found_servers
else:
raise ValueError(
f"Кластер или сервер с ID/именем '{cluster_id}' не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем '{cluster_id}' не найден.")
results = {}
tasks = []
@@ -969,25 +874,19 @@ async def toggle_client_on_cluster(
if panel_type == "3x-ui":
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск."
)
logger.warning(f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
xui = await get_xui_instance(server_info["api_url"])
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(
toggle_client(xui, int(inbound_id), unique_email, client_id, enable)
)
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
elif panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(
f"[Remnawave] Авторизация не удалась на сервере {server_name}"
)
logger.error(f"[Remnawave] Авторизация не удалась на сервере {server_name}")
results[server_name] = False
continue
@@ -1005,17 +904,13 @@ async def toggle_client_on_cluster(
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(
f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}"
)
logger.error(f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(
f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}"
)
logger.info(f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}")
logger.info(f"[Cluster Toggle DEBUG] Результаты: {results}")
return {
@@ -1024,15 +919,11 @@ async def toggle_client_on_cluster(
}
except Exception as e:
logger.error(
f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}"
)
logger.error(f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
async def reset_traffic_in_cluster(
cluster_id: str, email: str, session: AsyncSession
) -> None:
async def reset_traffic_in_cluster(cluster_id: str, email: str, session: AsyncSession) -> None:
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
@@ -1046,9 +937,7 @@ async def reset_traffic_in_cluster(
if found_servers:
cluster = found_servers
else:
raise ValueError(
f"Кластер или сервер с ID/именем {cluster_id} не найден."
)
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
remnawave_done = False
@@ -1061,25 +950,19 @@ async def reset_traffic_in_cluster(
if panel_type == "remnawave" and not remnawave_done:
result = await session.execute(
select(Key.client_id)
.where(Key.email == email, Key.server_id == cluster_id)
.limit(1)
select(Key.client_id).where(Key.email == email, Key.server_id == cluster_id).limit(1)
)
row = result.first()
if not row:
logger.warning(
f"[Remnawave Reset] client_id не найден для {email} на {server_name}"
)
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row[0]
remna = RemnawaveAPI(api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.warning(
f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})"
)
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
@@ -1088,26 +971,18 @@ async def reset_traffic_in_cluster(
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск."
)
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(
f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}"
)
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(
f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}"
)
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(
f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}"
)
raise
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+47 -97
View File
@@ -1,10 +1,12 @@
import html
import os
import re
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -37,11 +39,20 @@ from handlers.buttons import (
MAIN_MENU,
PC_BUTTON,
QR,
RENEW_SUB,
TV_BUTTON,
UNFREEZE,
RENEW_SUB
)
from handlers.texts import FROZEN_SUBSCRIPTION_MSG, NO_SUBSCRIPTIONS_MSG, key_message, KEYS_HEADER, KEYS_FOOTER, RENAME_KEY_PROMPT, DAYS_LEFT_MESSAGE, SELECT_SUBS
from handlers.texts import (
DAYS_LEFT_MESSAGE,
FROZEN_SUBSCRIPTION_MSG,
KEYS_FOOTER,
KEYS_HEADER,
NO_SUBSCRIPTIONS_MSG,
RENAME_KEY_PROMPT,
SELECT_SUBS,
key_message,
)
from handlers.utils import (
edit_or_send_message,
format_days,
@@ -52,6 +63,7 @@ from handlers.utils import (
)
from logger import logger
router = Router()
@@ -61,9 +73,7 @@ class RenameKeyState(StatesGroup):
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(
callback_query_or_message: Message | CallbackQuery, session: Any
):
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
@@ -105,19 +115,13 @@ def build_keys_response(records):
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(
expiry_time / 1000, tz=moscow_tz
)
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(
text=f"🔑 {key_display}", callback_data=f"view_key|{email}"
)
rename_button = InlineKeyboardButton(
text=ALIAS, callback_data=f"rename_key|{client_id}"
)
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
@@ -139,9 +143,7 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys"))
await edit_or_send_message(
target_message=callback.message,
@@ -151,24 +153,18 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
async def cancel_and_back(
callback: CallbackQuery, state: FSMContext, session: AsyncSession
):
async def cancel_and_back(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
await callback.answer()
await process_callback_or_message_view_keys(callback, session)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(
message: Message, state: FSMContext, session: AsyncSession
):
async def handle_new_alias_input(message: Message, state: FSMContext, session: AsyncSession):
alias = message.text.strip()
if len(alias) > 10:
await message.answer(
"❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод."
)
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
@@ -182,9 +178,7 @@ async def handle_new_alias_input(
try:
await session.execute(
update(Key)
.where(Key.tg_id == message.chat.id, Key.client_id == client_id)
.values(alias=alias)
update(Key).where(Key.tg_id == message.chat.id, Key.client_id == client_id).values(alias=alias)
)
await session.commit()
@@ -204,9 +198,7 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
await render_key_info(callback_query.message, session, key_name, image_path)
async def render_key_info(
message: Message, session: Any, key_name: str, image_path: str
):
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
@@ -225,11 +217,7 @@ async def render_key_info(
builder = InlineKeyboardBuilder()
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -252,9 +240,13 @@ async def render_key_info(
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"⏳ Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
days_left_message = (
f"⏳ Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
)
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
formatted_expiry_date = (
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
)
hwid_count = 0
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
@@ -262,12 +254,7 @@ async def render_key_info(
try:
servers = await get_servers(session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
)
if remna_server:
@@ -303,7 +290,7 @@ async def render_key_info(
tariff_name=tariff_duration,
traffic_limit=traffic_limit,
device_limit=device_limit,
subgroup_title=subgroup_title
subgroup_title=subgroup_title,
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
@@ -315,35 +302,17 @@ async def render_key_info(
)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")
)
builder.row(InlineKeyboardButton(text=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}"))
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"
)
)
builder.row(
InlineKeyboardButton(
text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"
),
InlineKeyboardButton(
text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"
),
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
if HWID_RESET_BUTTON and hwid_count > 0:
builder.row(
@@ -362,18 +331,10 @@ async def render_key_info(
)
if USE_COUNTRY_SELECTION:
builder.row(
InlineKeyboardButton(
text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE, callback_data=f"freeze_subscription|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -399,19 +360,12 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
client_id = record.get("client_id")
if not client_id:
await callback_query.answer(
"❌ У ключа отсутствует client_id.", show_alert=True
)
await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True)
return
servers = await get_servers(session=session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
)
if not remna_server:
@@ -420,9 +374,7 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
api = RemnawaveAPI(remna_server["api_url"])
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await callback_query.answer(
"❌ Авторизация в Remnawave не удалась.", show_alert=True
)
await callback_query.answer("❌ Авторизация в Remnawave не удалась.", show_alert=True)
return
devices = await api.get_user_hwid_devices(client_id)
@@ -433,9 +385,7 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
for device in devices:
if await api.delete_user_hwid_device(client_id, device["hwid"]):
deleted += 1
await callback_query.answer(
f"✅ Устройства сброшены ({deleted})", show_alert=True
)
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
@@ -452,7 +402,7 @@ async def process_renew_menu(callback_query_or_message: CallbackQuery | Message,
else:
target_message = callback_query_or_message
tg_id = callback_query_or_message.from_user.id
records = await get_keys(session, tg_id)
servers_dict = await get_servers(session)
all_server_names = set()
@@ -463,7 +413,7 @@ async def process_renew_menu(callback_query_or_message: CallbackQuery | Message,
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
for record in records:
if getattr(record, 'is_frozen', False):
if getattr(record, "is_frozen", False):
continue
alias = record.alias
email = record.email
+9 -26
View File
@@ -13,13 +13,12 @@ from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(
callback_query: CallbackQuery, session: AsyncSession
):
async def process_callback_update_subscription(callback_query: CallbackQuery, session: AsyncSession):
tg_id = callback_query.message.chat.id
email = callback_query.data.split("|")[1]
@@ -34,9 +33,7 @@ async def process_callback_update_subscription(
await process_callback_view_key(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
await handle_error(
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
)
await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}")
@router.callback_query(F.data.startswith("delete_key|"))
@@ -45,11 +42,7 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
try:
confirmation_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=APPLY, callback_data=f"confirm_delete|{client_id}"
)
],
[types.InlineKeyboardButton(text=APPLY, callback_data=f"confirm_delete|{client_id}")],
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
]
)
@@ -59,18 +52,14 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
else:
await callback_query.message.edit_text(
text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard)
except Exception as e:
logger.error(f"Ошибка при подготовке удаления ключа {client_id}: {e}")
@router.callback_query(F.data.startswith("confirm_delete|"))
async def process_callback_confirm_delete(
callback_query: CallbackQuery, session: AsyncSession
):
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, email)
@@ -78,9 +67,7 @@ async def process_callback_confirm_delete(
client_id = record["client_id"]
server_id = record["server_id"]
response_message = KEY_DELETED_MSG_SIMPLE
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
)
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await delete_key(session, client_id)
@@ -94,15 +81,11 @@ async def process_callback_confirm_delete(
try:
await delete_key_from_cluster(server_id, email, client_id, session)
except Exception as e:
logger.error(
f"Ошибка при удалении ключа {client_id} с сервера {server_id}: {e}"
)
logger.error(f"Ошибка при удалении ключа {client_id} с сервера {server_id}: {e}")
else:
response_message = "Ключ не найден или уже удален."
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
)
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await edit_or_send_message(
target_message=callback_query.message,
+24 -55
View File
@@ -6,6 +6,7 @@ import time
import urllib.parse
import aiohttp
from aiohttp import web
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,19 +16,17 @@ from config import (
RANDOM_SUBSCRIPTIONS,
SUPERNODE,
SUPPORT_CHAT_URL,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_servers
from database.models import Server
from handlers.texts import HAPP_ANNOUNCE, HIDDIFY_PROFILE_TITLE, SUBSCRIPTION_INFO_TEXT, V2RAYTUN_ANNOUNCE
from handlers.utils import convert_to_bytes
from handlers.texts import SUBSCRIPTION_INFO_TEXT, HAPP_ANNOUNCE, V2RAYTUN_ANNOUNCE, HIDDIFY_PROFILE_TITLE
from logger import logger
async def fetch_url_content(
url: str, identifier: str
) -> tuple[list[str], dict[str, str]]:
async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[str, str]]:
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
@@ -36,9 +35,7 @@ async def fetch_url_content(
content = await response.text()
lines = base64.b64decode(content).decode("utf-8").split("\n")
headers = {k.lower(): v for k, v in response.headers.items()}
logger.debug(
f"Fetched {url}: {len(lines)} lines, headers: {headers}"
)
logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
return lines, headers
return [], {}
except Exception as e:
@@ -50,9 +47,7 @@ async def combine_unique_lines(
urls: list[str], identifier: str, query_string: str
) -> tuple[list[str], list[dict[str, str]]]:
if SUPERNODE:
logger.info(
f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}"
)
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
if not urls:
return [], []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
@@ -78,7 +73,7 @@ async def get_subscription_urls(
urls = []
if USE_COUNTRY_SELECTION:
result = await session.execute(
select(Server.subscription_url).where(Server.server_name == server_id, Server.enabled == True)
select(Server.subscription_url).where(Server.server_name == server_id, Server.enabled is True)
)
server_data = result.scalar()
if server_data:
@@ -101,9 +96,7 @@ def calculate_traffic(
expiry_time_ms: int | None,
headers_list: list[dict[str, str]],
) -> str:
logger.debug(
f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}"
)
logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}")
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
upload = 0
@@ -144,9 +137,7 @@ def calculate_traffic(
country_remaining[country] = remaining_bytes
logger.debug(f"Found traffic: {value}{unit} for {country}")
consumed_traffic_bytes = (
total - sum(country_remaining.values()) if country_remaining else download
)
consumed_traffic_bytes = total - sum(country_remaining.values()) if country_remaining else download
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
download = max(download, consumed_traffic_bytes)
@@ -201,11 +192,9 @@ def prepare_headers(
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"support-url": SUPPORT_CHAT_URL,
"announce": "base64:"
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
"subscription-userinfo": subscription_userinfo,
}
@@ -215,8 +204,7 @@ def prepare_headers(
encoded_project_name = HIDDIFY_PROFILE_TITLE.format(project_name=project_name, key_info=key_info)
return {
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
@@ -226,11 +214,9 @@ def prepare_headers(
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"update-always": "true",
"announce": "base64:"
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce-url": f"{SUPPORT_CHAT_URL}",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
else:
@@ -239,8 +225,7 @@ def prepare_headers(
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
}
@@ -257,14 +242,10 @@ async def handle_subscription(request: web.Request) -> web.Response:
try:
key = await get_key_details(session, email)
if not key:
return web.Response(
text="❌ Клиент с таким email не найден.", status=404
)
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
if int(tg_id) != int(key["tg_id"]):
return web.Response(
text="❌ Неверные данные. Получите свой ключ в боте.", status=403
)
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
expiry_time_ms = key["expiry_time"]
server_id = key["server_id"]
@@ -272,38 +253,26 @@ async def handle_subscription(request: web.Request) -> web.Response:
time_left = format_time_left(expiry_time_ms)
urls = await get_subscription_urls(
server_id, email, session, include_remnawave_key=remnawave_link
)
urls = await get_subscription_urls(server_id, email, session, include_remnawave_key=remnawave_link)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string
combined_subscriptions, headers_list = await combine_unique_lines(
urls, tg_id or email, query_string
)
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
if RANDOM_SUBSCRIPTIONS:
random.shuffle(combined_subscriptions)
cleaned_subscriptions = [
clean_subscription_line(line) for line in combined_subscriptions
]
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
base64_encoded = base64.b64encode(
"\n".join(cleaned_subscriptions).encode("utf-8")
).decode("utf-8")
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
subscription_info = SUBSCRIPTION_INFO_TEXT.format(email=email, time_left=time_left)
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(
cleaned_subscriptions, expiry_time_ms, headers_list
)
headers = prepare_headers(
user_agent, PROJECT_NAME, subscription_info, subscription_userinfo
)
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms, headers_list)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
return web.Response(text=base64_encoded, headers=headers)
except Exception as e:
logger.error(f"Ошибка в handle_subscription: {e}", exc_info=True)
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
+1
View File
@@ -5,6 +5,7 @@ from aiogram import Router
from .general_notifications import router as general_notifications_router
from .special_notifications import router as special_notifications_router
router = Router(name="notifications_main_router")
router.include_routers(general_notifications_router, special_notifications_router)
+84 -168
View File
@@ -1,7 +1,9 @@
import asyncio
from datetime import datetime, timedelta
import pytz
from aiogram import Bot, Router
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -19,17 +21,17 @@ from database import (
add_notification,
check_notification_time,
check_notifications_bulk,
check_tariff_exists,
delete_key,
delete_notification,
get_all_keys,
get_balance,
get_last_notification_time,
get_tariff_by_id,
get_tariffs_for_cluster,
update_balance,
update_key_expiry,
update_key_tariff,
check_tariff_exists,
get_tariff_by_id,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import (
@@ -44,13 +46,14 @@ from handlers.texts import (
KEY_EXPIRY_24H,
get_renewal_message,
)
from handlers.utils import format_hours, format_minutes, get_russian_month, format_months, format_days
from handlers.utils import format_days, format_hours, format_minutes, format_months, get_russian_month
from logger import logger
from .hot_leads_notifications import notify_hot_leads
from .notify_utils import send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock()
@@ -69,13 +72,8 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
logger.info("🔔 Запуск обработки уведомлений")
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_10h = int(
(datetime.now(moscow_tz) + timedelta(hours=10)).timestamp()
* 1000
)
threshold_24h = int(
(datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000
)
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
try:
keys = await get_all_keys(session=session)
@@ -91,16 +89,12 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
try:
await notify_24h_keys(
bot, session, current_time, threshold_24h, keys
)
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
try:
await notify_10h_keys(
bot, session, current_time, threshold_10h, keys
)
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
@@ -111,9 +105,7 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
if NOTIFY_INACTIVE_TRAFFIC:
try:
await notify_users_no_traffic(
bot, session, current_time, keys
)
await notify_users_no_traffic(bot, session, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
@@ -142,19 +134,13 @@ async def notify_24h_keys(
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
key
for key in keys
if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h
]
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk(
session, "key_24h", 24, tg_ids=tg_ids, emails=emails
)
users = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
messages = []
@@ -163,24 +149,18 @@ async def notify_24h_keys(
email = key.email or ""
notification_id = f"{email}_key_24h"
can_notify = await check_notification_time(
session, tg_id, notification_id, hours=24
)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
if not can_notify:
continue
user = next(
(u for u in users if u["tg_id"] == tg_id and u["email"] == email), None
)
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -204,22 +184,18 @@ async def notify_24h_keys(
notification_text,
)
except Exception as e:
logger.error(
f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}"
)
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
messages.append(
{
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
@@ -229,16 +205,12 @@ async def notify_24h_keys(
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(
f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(
f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
@@ -253,19 +225,13 @@ async def notify_10h_keys(
):
logger.info("Начало проверки подписок, истекающих через 10 часов.")
expiring_keys = [
key
for key in keys
if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h
]
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
tg_ids = [key.tg_id for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk(
session, "key_10h", 10, tg_ids=tg_ids, emails=emails
)
users = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
@@ -273,24 +239,18 @@ async def notify_10h_keys(
email = key.email or ""
notification_id = f"{email}_key_10h"
can_notify = await check_notification_time(
session, tg_id, notification_id, hours=10
)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
if not can_notify:
continue
user = next(
(u for u in users if u["tg_id"] == tg_id and u["email"] == email), None
)
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -314,22 +274,18 @@ async def notify_10h_keys(
notification_text,
)
except Exception as e:
logger.error(
f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}"
)
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
messages.append(
{
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
@@ -339,16 +295,12 @@ async def notify_10h_keys(
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(
f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(
f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
@@ -362,16 +314,12 @@ async def handle_expired_keys(
):
logger.info("Начало обработки истекших ключей.")
expired_keys = [
key for key in keys if key.expiry_time and key.expiry_time < current_time
]
expired_keys = [key for key in keys if key.expiry_time and key.expiry_time < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
tg_ids = [key.tg_id for key in expired_keys]
emails = [key.email or "" for key in expired_keys]
users = await check_notifications_bulk(
session, "key_expired", 0, tg_ids=tg_ids, emails=emails
)
users = await check_notifications_bulk(session, "key_expired", 0, tg_ids=tg_ids, emails=emails)
messages = []
@@ -382,9 +330,7 @@ async def handle_expired_keys(
server_id = key.server_id
notification_id = f"{email}_key_expired"
last_notification_time = await get_last_notification_time(
session, tg_id, notification_id
)
last_notification_time = await get_last_notification_time(session, tg_id, notification_id)
if NOTIFY_RENEW_EXPIRED:
try:
@@ -404,7 +350,7 @@ async def handle_expired_keys(
tariff_name=tariff.get("name", ""),
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
subgroup_title=tariff.get("subgroup_title", "")
subgroup_title=tariff.get("subgroup_title", ""),
),
)
continue
@@ -417,9 +363,7 @@ async def handle_expired_keys(
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) >= NOTIFY_DELETE_DELAY
logger.info(
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
@@ -429,30 +373,22 @@ async def handle_expired_keys(
try:
await delete_key_from_cluster(server_id, email, client_id, session)
await delete_key(session, client_id)
logger.info(
f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён."
)
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
keyboard = build_notification_expired_kb()
messages.append(
{
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
messages.append({
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
except Exception as e:
logger.error(
f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}"
)
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
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(u["tg_id"] == tg_id and u["email"] == email for u in users):
keyboard = build_notification_kb(email)
if NOTIFY_DELETE_DELAY > 0:
@@ -464,24 +400,19 @@ async def handle_expired_keys(
time_formatted = format_hours(hours)
else:
time_formatted = format_minutes(minutes)
delay_message = KEY_EXPIRED_DELAY_MSG.format(
email=email,
time_formatted=time_formatted
)
delay_message = KEY_EXPIRED_DELAY_MSG.format(email=email, time_formatted=time_formatted)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
messages.append(
{
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
messages.append({
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
@@ -490,9 +421,7 @@ async def handle_expired_keys(
await add_notification(session, msg["tg_id"], msg["notification_id"])
if result:
sent_count += 1
logger.info(
f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}."
)
logger.info(f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе {msg['email']} пользователю {msg['tg_id']}."
@@ -518,9 +447,7 @@ async def process_auto_renew_or_notify(
renew_notification_id = f"{email}_renew"
try:
can_renew = await check_notification_time(
conn, tg_id, renew_notification_id, hours=24
)
can_renew = await check_notification_time(conn, tg_id, renew_notification_id, hours=24)
if not can_renew:
logger.info(
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
@@ -533,9 +460,7 @@ async def process_auto_renew_or_notify(
tariffs = await get_tariffs_for_cluster(conn, server_id)
if not tariffs:
logger.warning(
f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})"
)
logger.warning(f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})")
return
selected_tariff = None
@@ -573,15 +498,13 @@ async def process_auto_renew_or_notify(
if not selected_tariff:
keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id)
await send_notification(
bot, tg_id, standard_photo, standard_caption, keyboard
)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
return
client_id = key.client_id
current_expiry = key.expiry_time
duration_days = selected_tariff["duration_days"]
tariff_duration = selected_tariff["name"]
selected_tariff["name"]
renewal_cost = selected_tariff["price_rub"]
traffic_limit = selected_tariff["traffic_limit"]
device_limit = selected_tariff["device_limit"]
@@ -593,13 +516,11 @@ async def process_auto_renew_or_notify(
else datetime.utcnow().timestamp() * 1000
) + duration_days * 24 * 60 * 60 * 1000
formatted_expiry_date = datetime.fromtimestamp(
new_expiry_time / 1000, tz=moscow_tz
).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
)
logger.info(
@@ -613,7 +534,7 @@ async def process_auto_renew_or_notify(
new_expiry_time=int(new_expiry_time),
total_gb=total_gb,
hwid_device_limit=device_limit,
session=conn
session=conn,
)
await update_balance(conn, tg_id, -renewal_cost)
await update_key_expiry(conn, client_id, int(new_expiry_time))
@@ -623,25 +544,20 @@ async def process_auto_renew_or_notify(
renewed_message = get_renewal_message(
tariff_name=selected_tariff["name"],
traffic_limit=selected_tariff.get("traffic_limit") if selected_tariff.get("traffic_limit") is not None else 0,
traffic_limit=selected_tariff.get("traffic_limit")
if selected_tariff.get("traffic_limit") is not None
else 0,
device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date,
subgroup_title=selected_tariff.get("subgroup_title", "")
subgroup_title=selected_tariff.get("subgroup_title", ""),
)
keyboard = build_notification_expired_kb()
result = await send_notification(
bot, tg_id, "notify_expired.jpg", renewed_message, keyboard
)
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(
f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}."
)
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
)
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
@@ -27,11 +27,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
for tg_id in leads:
has_step_1 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_1")
.exists()
)
select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_1").exists())
)
if not has_step_1:
await add_notification(session, tg_id, "hot_lead_step_1")
@@ -39,11 +35,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
continue
has_step_2 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2")
.exists()
)
select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_2").exists())
)
if not has_step_2:
can_send = await check_notification_time(
@@ -56,9 +48,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
continue
keyboard = build_hot_lead_kb()
result = await send_notification(
bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard
)
result = await send_notification(bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard)
if result:
await add_notification(session, tg_id, "hot_lead_step_2")
logger.info(f"🔥 Шаг 2 — отправлено первое уведомление: {tg_id}")
@@ -66,17 +56,11 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
continue
has_step_3 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_3")
.exists()
)
select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_3").exists())
)
has_expired_notification = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2_expired")
.exists()
select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_2_expired").exists()
)
)
if not has_step_3 and not has_expired_notification:
@@ -88,20 +72,12 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
)
if expired:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
result = await send_notification(
bot, tg_id, None, HOT_LEAD_LOST_OPPORTUNITY, builder.as_markup()
)
result = await send_notification(bot, tg_id, None, HOT_LEAD_LOST_OPPORTUNITY, builder.as_markup())
if result:
await add_notification(
session, tg_id, "hot_lead_step_2_expired"
)
logger.info(
f"📭 Скидка упущена — отправлено уведомление: {tg_id}"
)
await add_notification(session, tg_id, "hot_lead_step_2_expired")
logger.info(f"📭 Скидка упущена — отправлено уведомление: {tg_id}")
continue
if not has_step_3:
@@ -115,9 +91,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
continue
keyboard = build_hot_lead_kb(final=True)
result = await send_notification(
bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard
)
result = await send_notification(bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard)
if result:
await add_notification(session, tg_id, "hot_lead_step_3")
logger.info(f"⚡ Шаг 3 — отправлено финальное уведомление: {tg_id}")
+2 -6
View File
@@ -35,18 +35,14 @@ def build_hot_lead_kb(final: bool = False) -> InlineKeyboardMarkup:
[
InlineKeyboardButton(
text=DISCOUNT_TARIFF if not final else MAX_DISCOUNT_TARIFF,
callback_data=(
"hot_lead_discount" if not final else "hot_lead_final_discount"
),
callback_data=("hot_lead_discount" if not final else "hot_lead_final_discount"),
)
]
]
)
def build_tariffs_keyboard(
tariffs: list[dict], prefix: str = "tariff"
) -> InlineKeyboardMarkup:
def build_tariffs_keyboard(tariffs: list[dict], prefix: str = "tariff") -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
+8 -23
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import aiofiles
from aiogram import Bot
from aiogram.exceptions import (
TelegramBadRequest,
@@ -32,10 +33,7 @@ async def send_messages_with_limit(
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
tasks = [
send_notification(
bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")
)
for msg in batch
send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")) for msg in batch
]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -48,17 +46,12 @@ async def send_messages_with_limit(
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
elif (
isinstance(result, TelegramBadRequest)
and "chat not found" in str(result).lower()
):
elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
else:
logger.warning(
f"📩 Не удалось отправить уведомление пользователю {tg_id}."
)
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
@@ -67,9 +60,7 @@ async def send_messages_with_limit(
return results
async def try_add_blocked_user(
tg_id: int, session: AsyncSession, source_file: str | None
):
async def try_add_blocked_user(tg_id: int, session: AsyncSession, source_file: str | None):
"""
Добавляет пользователя в список заблокировавших бота, если требуется.
"""
@@ -121,9 +112,7 @@ async def send_notification(
photo_path = os.path.join("img", image_filename)
if os.path.isfile(photo_path):
return await _send_photo_notification(
bot, tg_id, photo_path, image_filename, caption, keyboard
)
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
else:
logger.warning(f"Файл с изображением не найден: {photo_path}")
return await _send_text_notification(bot, tg_id, caption, keyboard)
@@ -143,9 +132,7 @@ async def _send_photo_notification(
async with aiofiles.open(photo_path, "rb") as image_file:
image_data = await image_file.read()
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
await bot.send_photo(
tg_id, buffered_photo, caption=caption, reply_markup=keyboard
)
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
return True
except (TelegramForbiddenError, TelegramBadRequest):
return False
@@ -168,7 +155,5 @@ async def _send_text_notification(
except (TelegramForbiddenError, TelegramBadRequest):
return False
except Exception as e:
logger.error(
f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}"
)
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
return False
+35 -80
View File
@@ -1,16 +1,18 @@
from datetime import datetime, timedelta
import pytz
from aiogram import Bot, Router, types
from aiogram.types import InlineKeyboardButton, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from config import (
CONNECT_PHONE_BUTTON,
NOTIFY_EXTRA_DAYS,
NOTIFY_INACTIVE,
NOTIFY_INACTIVE_TRAFFIC,
SUPPORT_CHAT_URL,
CONNECT_PHONE_BUTTON,
TRIAL_CONFIG,
)
from database import (
@@ -20,7 +22,7 @@ from database import (
update_key_notified,
)
from database.models import Key
from handlers.buttons import MAIN_MENU, CONNECT_DEVICE, CONNECT_PHONE, PC_BUTTON, TV_BUTTON
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, TV_BUTTON
from handlers.keys.key_utils import get_user_traffic
from handlers.notifications.notify_utils import send_messages_with_limit
from handlers.texts import (
@@ -52,11 +54,7 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
display_name = username or first_name or last_name or "Пользователь"
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период", callback_data="create_key"
)
)
builder.row(types.InlineKeyboardButton(text="🚀 Активировать пробный период", callback_data="create_key"))
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
@@ -76,14 +74,12 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
trial_time_formatted=format_days(trial_days),
)
messages.append(
{
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"notification_id": "inactive_trial",
}
)
messages.append({
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"notification_id": "inactive_trial",
})
if messages:
results = await send_messages_with_limit(
@@ -102,9 +98,7 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
async def notify_users_no_traffic(
bot: Bot, session: AsyncSession, current_time: int, keys: list
):
async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time: int, keys: list):
logger.info("Проверка пользователей с нулевым трафиком...")
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
messages = []
@@ -120,19 +114,13 @@ async def notify_users_no_traffic(
if created_at is None or notified:
continue
created_at_dt = pytz.utc.localize(
datetime.fromtimestamp(created_at / 1000)
).astimezone(moscow_tz)
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
if current_dt < created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC):
continue
if expiry_time:
expiry_dt = pytz.utc.localize(
datetime.fromtimestamp(expiry_time / 1000)
).astimezone(moscow_tz)
if (current_dt - (expiry_dt - timedelta(days=30))) < timedelta(
hours=NOTIFY_INACTIVE_TRAFFIC
):
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
if (current_dt - (expiry_dt - timedelta(days=30))) < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC):
continue
try:
@@ -142,14 +130,11 @@ async def notify_users_no_traffic(
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(
value if isinstance(value, int | float) else 0
for value in traffic_data.get("traffic", {}).values()
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
)
try:
@@ -159,69 +144,39 @@ async def notify_users_no_traffic(
continue
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
try:
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:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
else:
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{email}"))
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}"
),
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}"
)
)
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
except Exception as e:
logger.error(f"Ошибка при определении типа панели для {email}: {e}")
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"
)
)
builder.row(
InlineKeyboardButton(
text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL
)
)
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
builder.row(InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
message = ZERO_TRAFFIC_MSG.format(email=email)
messages.append(
{
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"client_id": client_id,
}
)
messages.append({
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"client_id": client_id,
})
if messages:
results = await send_messages_with_limit(
+15 -17
View File
@@ -1,4 +1,5 @@
import os
from typing import Any
from aiogram import F, Router
@@ -14,11 +15,11 @@ from config import (
FREEKASSA_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
WATA_INT_ENABLE,
WATA_RU_ENABLE,
WATA_SBP_ENABLE,
WATA_INT_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
)
from database import get_last_payments
from database.models import User
@@ -31,31 +32,29 @@ from handlers.buttons import (
PAYMENT,
ROBOKASSA,
STARS,
YOOKASSA,
YOOMONEY,
WATA_INT,
WATA_RU,
WATA_SBP,
WATA_INT,
YOOKASSA,
YOOMONEY,
)
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
from handlers.payments.freekassa_pay import process_callback_pay_freekassa
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
from handlers.payments.stars_pay import process_callback_pay_stars
from handlers.payments.wata import process_callback_pay_wata
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
from handlers.payments.wata import process_callback_pay_wata
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "pay")
async def handle_pay(
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
):
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
payment_handlers = []
if YOOKASSA_ENABLE:
@@ -98,9 +97,7 @@ async def handle_pay(
if WATA_INT_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_INT, callback_data="pay_wata_int"))
if DONATIONS_ENABLE:
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
)
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -120,9 +117,7 @@ async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(
InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history")
)
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -166,14 +161,17 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
disable_web_page_preview=False,
)
@router.callback_query(F.data == "pay_wata_ru")
async def handle_pay_wata_ru(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="ru")
@router.callback_query(F.data == "pay_wata_sbp")
async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="sbp")
@router.callback_query(F.data == "pay_wata_int")
async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="int")
+3 -4
View File
@@ -21,11 +21,11 @@ from handlers.buttons import (
ADD_SUB,
BACK,
BALANCE,
RENEW_KEY,
GIFTS,
INSTRUCTIONS,
INVITE,
MY_SUBS,
RENEW_KEY,
TRIAL_SUB,
)
from handlers.texts import ADD_SUBSCRIPTION_HINT
@@ -35,6 +35,7 @@ from .admin.panel.keyboard import AdminPanelCallback
from .texts import profile_message_send
from .utils import edit_or_send_message
router = Router()
@@ -102,9 +103,7 @@ async def process_callback_view_profile(
builder.row(*row_buttons)
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions")
)
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(
+16 -31
View File
@@ -1,7 +1,9 @@
import os
from io import BytesIO
import qrcode
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
@@ -18,7 +20,7 @@ from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import ADMIN_ID, INLINE_MODE, TOP_REFERRAL_BUTTON, TRIAL_CONFIG, USERNAME_BOT, REFERRAL_BONUS_PERCENTAGES
from config import ADMIN_ID, INLINE_MODE, REFERRAL_BONUS_PERCENTAGES, TOP_REFERRAL_BUTTON, TRIAL_CONFIG, USERNAME_BOT
from database import (
add_referral,
add_user,
@@ -29,26 +31,25 @@ from database import (
from database.models import Referral
from handlers.buttons import BACK, INVITE, MAIN_MENU, QR, TOP_FIVE
from handlers.texts import (
INVITE_MESSAGE_TEMPLATE,
INVITE_TEXT_NON_INLINE,
NEW_REFERRAL_NOTIFICATION,
REFERRAL_OFFERS,
REFERRAL_SUCCESS_MSG,
TOP_REFERRALS_TEXT,
INVITE_MESSAGE_TEMPLATE,
)
from logger import logger
from .texts import get_referral_link
from .utils import edit_or_send_message, format_days
router = Router()
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(
callback_query_or_message: Message | CallbackQuery, session: AsyncSession
):
async def invite_handler(callback_query_or_message: Message | CallbackQuery, session: AsyncSession):
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
@@ -68,7 +69,7 @@ async def invite_handler(
bonuses_block = "\n".join(bonuses_lines)
details_lines = []
for level, stats in referral_stats['referrals_by_level'].items():
for level, stats in referral_stats["referrals_by_level"].items():
bonus_value = REFERRAL_BONUS_PERCENTAGES.get(level)
if isinstance(bonus_value, float):
bonus_str = f"{int(bonus_value * 100)}%"
@@ -80,9 +81,9 @@ async def invite_handler(
invite_message = INVITE_MESSAGE_TEMPLATE.format(
referral_link=referral_link,
bonuses_block=bonuses_block,
total_referrals=referral_stats['total_referrals'],
total_referrals=referral_stats["total_referrals"],
details_block=details_block,
total_referral_bonus=referral_stats['total_referral_bonus'],
total_referral_bonus=referral_stats["total_referral_bonus"],
)
image_path = os.path.join("img", "pic_invite.jpg")
@@ -109,18 +110,14 @@ async def invite_handler(
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = (
f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
)
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
trial_days = TRIAL_CONFIG["duration_days"]
trial_time_formatted = format_days(trial_days)
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
message_text = offer["message"].format(
trial_time_formatted=trial_time_formatted
)[:4096]
message_text = offer["message"].format(trial_time_formatted=trial_time_formatted)[:4096]
title = offer["title"].format(trial_time_formatted=trial_time_formatted)
description = offer["description"]
@@ -132,9 +129,7 @@ async def inline_referral_handler(inline_query: InlineQuery):
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(),
)
)
@@ -175,9 +170,7 @@ async def show_referral_qr(callback_query: CallbackQuery):
os.remove(qr_path)
except Exception as e:
logger.error(
f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True
)
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@@ -185,11 +178,7 @@ async def show_referral_qr(callback_query: CallbackQuery):
async def top_referrals_handler(callback_query: CallbackQuery, session: AsyncSession):
user_id = callback_query.from_user.id
result = await session.execute(
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == user_id)
)
result = await session.execute(select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == user_id))
user_referral_count = result.scalar_one() or 0
personal_block = "Твоё место в рейтинге:\n"
@@ -254,9 +243,7 @@ async def handle_referral_link(
user_id = user["tg_id"] if isinstance(user, dict) else user.id
if referrer_tg_id == user_id:
await message.answer(
"❌ Вы не можете быть реферальной ссылкой самого себя."
)
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
return
existing_referral = await get_referral_by_referred_id(session, user_id)
@@ -290,9 +277,7 @@ async def handle_referral_link(
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
)
except Exception as e:
logger.error(
f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}"
)
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
+18 -41
View File
@@ -1,4 +1,5 @@
import os
from typing import Any
from aiogram import F, Router
@@ -23,9 +24,9 @@ from config import (
from database import (
add_user,
check_user_exists,
get_trial,
get_key_count,
get_coupon_by_code,
get_key_count,
get_trial,
)
from database.models import TrackingSource, User
from handlers.buttons import (
@@ -56,6 +57,7 @@ from .admin.panel.keyboard import AdminPanelCallback
from .refferal import handle_referral_link
from .utils import edit_or_send_message
router = Router()
processing_gifts = set()
@@ -73,9 +75,7 @@ async def handle_start_callback_query(
@router.message(Command("start"))
async def start_command(
message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True
):
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
if CAPTCHA_ENABLE and captcha:
@@ -95,12 +95,7 @@ async def start_command(
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(
callback_query: CallbackQuery,
state: FSMContext,
session: Any,
admin: bool
):
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback_query.from_user.id
logger.info(f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}")
@@ -112,11 +107,7 @@ async def check_subscription_callback(
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
builder.row(
InlineKeyboardButton(
text=SUB_CHANELL_DONE, callback_data="check_subscription"
)
)
builder.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
await callback_query.message.edit_text(
SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
@@ -178,9 +169,7 @@ async def process_start_logic(
text = text_to_process or message.text or message.caption
if not text:
logger.info(
f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}"
)
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
await show_start_menu(message, admin, session)
return
@@ -200,7 +189,7 @@ async def process_start_logic(
coupon = await get_coupon_by_code(session, coupon_code)
if not coupon:
continue
await activate_coupon(
message,
state,
@@ -209,7 +198,7 @@ async def process_start_logic(
admin=admin,
user_data=user_data,
)
if coupon.days:
return
continue
@@ -229,25 +218,21 @@ async def process_start_logic(
return await process_callback_view_profile(message, state, admin, session)
processing_gifts.add(gift_id)
try:
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
await handle_gift_link(
gift_id, message, state, session, user_data=user_data
)
await handle_gift_link(gift_id, message, state, session, user_data=user_data)
gift_detected = True
finally:
processing_gifts.discard(gift_id)
break
if "referral" in part:
referrer_tg_id = part.split("referral")[1].strip("_")
try:
referrer_tg_id = int(referrer_tg_id)
await handle_referral_link(
referrer_tg_id, message, state, session, user_data=user_data
)
await handle_referral_link(referrer_tg_id, message, state, session, user_data=user_data)
except (ValueError, IndexError):
pass
continue
@@ -255,9 +240,7 @@ async def process_start_logic(
if "utm" in part:
utm_code = part
logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
await handle_utm_link(
utm_code, message, state, session, user_data=user_data
)
await handle_utm_link(utm_code, message, state, session, user_data=user_data)
continue
await state.clear()
@@ -297,9 +280,7 @@ async def handle_utm_link(
):
user_id = user_data["tg_id"]
result = await session.execute(
select(TrackingSource).where(TrackingSource.code == utm_code)
)
result = await session.execute(select(TrackingSource).where(TrackingSource.code == utm_code))
utm_exists = result.scalar_one_or_none()
if not utm_exists:
@@ -314,9 +295,7 @@ async def handle_utm_link(
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
elif not user:
await add_user(session=session, source_code=utm_code, **user_data)
logger.info(
f"[UTM] Зарегистрирован и привязан {utm_code} к пользователю {user_id}"
)
logger.info(f"[UTM] Зарегистрирован и привязан {utm_code} к пользователю {user_id}")
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
@@ -376,9 +355,7 @@ async def handle_about_vpn(callback_query: CallbackQuery, session: AsyncSession)
builder = InlineKeyboardBuilder()
if DONATIONS_ENABLE:
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
)
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
if CHANNEL_EXISTS:
+16 -44
View File
@@ -2,9 +2,11 @@ import os
import re
import secrets
import string
from datetime import datetime
import aiofiles
from aiogram.types import (
BufferedInputFile,
InlineKeyboardMarkup,
@@ -17,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import ADMIN_ID
from database import get_servers
from database.models import Key, Server, Notification
from database.models import Key, Notification, Server
from logger import logger
@@ -25,14 +27,7 @@ def generate_random_email(length: int = 8) -> str:
"""
Генерирует случайный email с заданной длиной.
"""
return (
"".join(
secrets.choice(string.ascii_lowercase + string.digits)
for _ in range(length)
)
if length > 0
else ""
)
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
async def get_least_loaded_cluster(session: AsyncSession) -> str:
@@ -56,11 +51,8 @@ async def get_least_loaded_cluster(session: AsyncSession) -> str:
available_clusters = {}
for cluster_name, cluster_servers in servers.items():
enabled_servers = [
server for server in cluster_servers
if server.get("enabled", True)
]
enabled_servers = [server for server in cluster_servers if server.get("enabled", True)]
if not enabled_servers:
continue
@@ -78,9 +70,7 @@ async def get_least_loaded_cluster(session: AsyncSession) -> str:
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
raise ValueError("⚠️ Сервисы временно недоступны. Попробуйте позже.")
least_loaded_cluster = min(
available_clusters, key=lambda k: (available_clusters[k], k)
)
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
logger.info(
f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster} (загрузка: {available_clusters[least_loaded_cluster]})"
)
@@ -97,15 +87,11 @@ async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bo
identifier = cluster_name if cluster_name else server_name
result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == identifier)
)
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == identifier))
total_keys = result.scalar() or 0
if total_keys >= max_keys:
logger.warning(
f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}"
)
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
return False
usage_percent = total_keys / max_keys
@@ -114,9 +100,7 @@ async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bo
notif_key = f"server_warn_{server_name}"
result = await session.execute(
select(Notification).where(
Notification.tg_id == 0, Notification.notification_type == notif_key
)
select(Notification).where(Notification.tg_id == 0, Notification.notification_type == notif_key)
)
already_sent = result.scalar_one_or_none()
@@ -137,18 +121,14 @@ async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bo
return True
async def handle_error(
tg_id: int, callback_query: object | None = None, message: str = ""
) -> None:
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
"""
Обрабатывает ошибку, отправляя сообщение пользователю.
"""
try:
if callback_query and hasattr(callback_query, "message"):
try:
await bot.delete_message(
chat_id=tg_id, message_id=callback_query.message.message_id
)
await bot.delete_message(chat_id=tg_id, message_id=callback_query.message.message_id)
except Exception as delete_error:
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
@@ -226,9 +206,7 @@ async def edit_or_send_message(
return
except Exception:
await target_message.answer_photo(
photo=BufferedInputFile(
image_data, filename=os.path.basename(media_path)
),
photo=BufferedInputFile(image_data, filename=os.path.basename(media_path)),
caption=text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
@@ -237,9 +215,7 @@ async def edit_or_send_message(
else:
if not force_text and target_message.caption is not None:
try:
await target_message.edit_caption(
caption=text, reply_markup=reply_markup
)
await target_message.edit_caption(caption=text, reply_markup=reply_markup)
return
except Exception as e:
logger.error(f"Ошибка редактирования подписи: {e}")
@@ -271,17 +247,13 @@ def convert_to_bytes(value: float, unit: str) -> int:
async def is_full_remnawave_cluster(cluster_id: str, session: AsyncSession) -> bool:
result = await session.execute(
select(Server.panel_type).where(Server.cluster_name == cluster_id)
)
result = await session.execute(select(Server.panel_type).where(Server.cluster_name == cluster_id))
panel_types = result.scalars().all()
if panel_types:
return all(pt.lower() == "remnawave" for pt in panel_types)
result = await session.execute(
select(Server.panel_type).where(Server.server_name == cluster_id)
)
result = await session.execute(select(Server.panel_type).where(Server.server_name == cluster_id))
panel_type = result.scalar_one_or_none()
return panel_type and panel_type.lower() == "remnawave"
+2
View File
@@ -1,12 +1,14 @@
import logging
import os
import sys
from datetime import timedelta
from loguru import logger
from config import LOG_ROTATION_TIME
log_folder = "logs"
if not os.path.exists(log_folder):
+2 -8
View File
@@ -25,9 +25,7 @@ def register_middleware(
"""Регистрирует middleware в диспетчере."""
if middlewares is None:
available_middlewares = {
"session": (
SessionMiddleware(sessionmaker) if sessionmaker else SessionMiddleware()
),
"session": (SessionMiddleware(sessionmaker) if sessionmaker else SessionMiddleware()),
"admin": AdminMiddleware(),
"maintenance": MaintenanceModeMiddleware(),
"logging": LoggingMiddleware(),
@@ -40,11 +38,7 @@ def register_middleware(
dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker))
exclude_set = set(exclude or [])
middlewares = [
middleware
for name, middleware in available_middlewares.items()
if name not in exclude_set
]
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
handlers = [
dispatcher.message,

Some files were not shown because too many files have changed in this diff Show More