Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c67c401b9 | |||
| 10eca6c6ac | |||
| b947a48ca2 | |||
| 39701b1f55 | |||
| 5f69a36077 | |||
| 2556d3842b | |||
| ffec36fa54 | |||
| da6815c5f1 | |||
| 76c0c468f5 | |||
| 357c8a01e7 | |||
| 595c312e2b | |||
| 28435d2943 | |||
| 171634522e | |||
| 424cb50942 | |||
| 15ce453523 | |||
| cfe8b2edcd | |||
| 3b64b28b86 | |||
| 918872e4b4 | |||
| f21b49d5fd | |||
| 2bf2d98c88 | |||
| 63cc967dfc | |||
| 9bea1d6a6c | |||
| 97234d8835 | |||
| c5e8917043 | |||
| b39ae1a1ee | |||
| d5190ea07e | |||
| 7f35ec0745 | |||
| 16b22101af | |||
| ccdb490b84 | |||
| c12e61fb41 | |||
| c877aef711 | |||
| a174b7bd81 | |||
| 43f4657089 | |||
| 50d2d5f716 | |||
| 34618a3de1 | |||
| 1b930460ca | |||
| fe439cd31d | |||
| 81733b041a | |||
| 7ac599aa18 | |||
| 279409600b | |||
| d701ce31c8 | |||
| 315459ed2e | |||
| 0dd3641151 | |||
| c610fb118e | |||
| 8f471a1e43 | |||
| c7b2f15131 | |||
| 12079cac34 | |||
| 96b283c6cb | |||
| aeee2ec352 | |||
| 19c0db3690 | |||
| ede74ef7af | |||
| 4607c81e7f | |||
| cc70ac8f14 | |||
| f7eb475292 | |||
| 38da3cca91 | |||
| bf1e454f0c | |||
| 36ea55a10d | |||
| 9ee2f0addf | |||
| 52aa6f81e2 | |||
| b1c53d5fad | |||
| 52d5f784a2 | |||
| cafe576478 | |||
| 9427cb20d9 | |||
| 68b9092749 | |||
| e3887fa66a | |||
| ffd08e726d | |||
| 03444b65ba | |||
| b817b5f087 | |||
| f93dc40f5f | |||
| 71c8014be1 | |||
| 33e44abef0 | |||
| e7f91a971b | |||
| 8ee133edc6 | |||
| 8954d3a756 | |||
| 0640c4f1fd | |||
| 2c9680cc8d | |||
| 9d3c0cb4b1 | |||
| 6bfa94d9bb | |||
| 58cda2e5ff | |||
| 2d4cdb0e35 | |||
| 1331632bdc | |||
| eadcacdedc | |||
| 855398d186 | |||
| 8bd09018ec | |||
| 36b074b209 | |||
| 318598026c | |||
| 755c85db2d | |||
| 9074eb277d | |||
| 55cbe579f7 | |||
| d9292840c5 | |||
| f817110476 | |||
| 4699c6a394 | |||
| 0abb83aae1 | |||
| d532f1211c | |||
| e4538ec0f2 | |||
| 157f7454bd | |||
| d9f6013aa1 | |||
| a3d8a6233a | |||
| 5ecb5b66f0 | |||
| c9797f9b26 | |||
| 19063b3592 | |||
| f0c1f62d62 | |||
| e7410ae776 | |||
| 606af6f52a | |||
| 3f22e20c24 | |||
| c2110e7304 | |||
| c07e55a86e | |||
| 48e15fbaad | |||
| e280a4dc12 | |||
| 493f01d6aa | |||
| a93855e1a6 | |||
| a9821aced7 | |||
| 98055bc1ab | |||
| 801dac4884 | |||
| a315222df7 | |||
| 34feb87b0a | |||
| ab04498488 | |||
| 6f8bc8ad24 | |||
| 1e6c901e43 | |||
| 2aade7acf9 | |||
| 9ade45347c | |||
| 1fb7f8d412 | |||
| aabfaf10ee |
@@ -17,7 +17,7 @@
|
||||
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
|
||||
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
|
||||
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
|
||||
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars |
|
||||
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars <br> • Heleket (Физ. лица) [@JustYay](https://github.com/JustYay) <br> • Wata (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Kassai (Физ. лица) [@JustYay](https://github.com/JustYay)|
|
||||
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
|
||||
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
|
||||
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import hashlib
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Admin:
|
||||
hashed = hash_token(token)
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return admin
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
from fastapi import FastAPI
|
||||
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (preAlpha)",
|
||||
version="0.2.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json"
|
||||
)
|
||||
|
||||
|
||||
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
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)
|
||||
return value
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
schema_response: type,
|
||||
schema_create: type,
|
||||
schema_update: type,
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
async def get_all(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
return result.scalars().all()
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return obj
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def get_one(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return obj
|
||||
|
||||
if "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: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return objs
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: schema_create, # type: ignore
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.dict(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: schema_update, # type: ignore
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from database.models import Coupon
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Coupon,
|
||||
schema_response=CouponResponse,
|
||||
schema_create=CouponBase,
|
||||
schema_update=CouponUpdate,
|
||||
identifier_field="code",
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
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 Admin, Gift, GiftUsage
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
schema_create=GiftBase,
|
||||
schema_update=GiftUpdate,
|
||||
identifier_field="gift_id",
|
||||
parameter_name="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
return gifts
|
||||
|
||||
|
||||
gift_usage_router = generate_crud_router(
|
||||
model=GiftUsage,
|
||||
schema_response=GiftUsageResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="gift_id",
|
||||
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"])
|
||||
@@ -0,0 +1,141 @@
|
||||
from datetime import datetime
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
try:
|
||||
await delete_key_from_cluster(
|
||||
session=session,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
cluster_id=db_key.server_id,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
await session.commit()
|
||||
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
|
||||
return {"message": "Ключ успешно удалён"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при удалении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
|
||||
|
||||
|
||||
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
|
||||
async def get_router_keys_by_tg_id(
|
||||
tg_id: int = Path(..., description="Telegram 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"))
|
||||
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 = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
|
||||
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
|
||||
async def edit_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
key_update: KeyUpdate = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
for field, value in key_update.dict(exclude_unset=True).items():
|
||||
if field == "expiry_time" and value is not None:
|
||||
if isinstance(value, int):
|
||||
ms = value
|
||||
elif isinstance(value, datetime):
|
||||
ms = int(value.timestamp() * 1000)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Некорректный формат времени")
|
||||
setattr(db_key, field, ms)
|
||||
else:
|
||||
setattr(db_key, field, value)
|
||||
|
||||
try:
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
|
||||
return db_key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
|
||||
|
||||
|
||||
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_key_api(
|
||||
payload: KeyCreateRequest = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_id=payload.cluster_id,
|
||||
tg_id=payload.tg_id,
|
||||
client_id=payload.client_id,
|
||||
email=payload.email or f"{payload.tg_id}_key",
|
||||
expiry_timestamp=payload.expiry_timestamp,
|
||||
plan=payload.tariff_id,
|
||||
session=session,
|
||||
remnawave_link=payload.remnawave_link,
|
||||
hwid_limit=payload.hwid_limit,
|
||||
traffic_limit_bytes=payload.traffic_limit_bytes,
|
||||
is_trial=payload.is_trial or False,
|
||||
)
|
||||
return {"message": "Ключ успешно создан"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
@@ -0,0 +1,141 @@
|
||||
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 (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
ReferralResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
TemporaryData,
|
||||
TrackingSource,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Payment,
|
||||
schema_response=PaymentResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/payments",
|
||||
tags=["Payments"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
|
||||
async def get_payments_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
return payments
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referred_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/referrals",
|
||||
tags=["Referrals"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Notification,
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
tags=["Notifications"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=ManualBan,
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=BlockedUser,
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TemporaryData,
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
tags=["TemporaryData"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TrackingSource,
|
||||
schema_response=TrackingSourceResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/tracking-sources",
|
||||
tags=["TrackingSources"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
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 Admin, Referral
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/one")
|
||||
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),
|
||||
):
|
||||
result = await session.execute(
|
||||
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"}
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Server,
|
||||
schema_response=ServerResponse,
|
||||
schema_create=ServerBase,
|
||||
schema_update=ServerUpdate,
|
||||
identifier_field="server_name",
|
||||
parameter_name="server_name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from database.models import Tariff
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Tariff,
|
||||
schema_response=TariffResponse,
|
||||
schema_create=TariffBase,
|
||||
schema_update=TariffUpdate,
|
||||
identifier_field="name",
|
||||
parameter_name="name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import 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.users import UserBase, UserResponse, UserUpdate
|
||||
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 logger import logger
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
key_records = result.all()
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
servers = await get_servers(session=session)
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
for cluster_id in servers:
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
@@ -0,0 +1,15 @@
|
||||
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 .servers import ServerBase, ServerResponse, ServerUpdate
|
||||
from .tariffs import TariffBase, TariffResponse, TariffUpdate
|
||||
from .users import UserBase, UserResponse, UserUpdate
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class CouponBase(BaseModel):
|
||||
code: str
|
||||
amount: int | None = Field(default=None)
|
||||
usage_limit: int
|
||||
usage_count: int = 0
|
||||
is_used: bool = False
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
|
||||
if has_amount and has_days:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
|
||||
if not has_amount and not has_days:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
|
||||
return self
|
||||
|
||||
|
||||
class CouponResponse(CouponBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CouponUpdate(BaseModel):
|
||||
code: 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":
|
||||
if self.amount is None and self.days is None:
|
||||
return self
|
||||
if self.amount is not None and self.days is not None:
|
||||
raise ValueError("Specify only one of: 'amount' or 'days'")
|
||||
return self
|
||||
|
||||
|
||||
class CouponUsageResponse(BaseModel):
|
||||
coupon_id: int
|
||||
user_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
is_used: bool = False
|
||||
is_unlimited: bool | None = False
|
||||
max_usages: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
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
|
||||
@@ -0,0 +1,78 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
tg_id: int
|
||||
client_id: str
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
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):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyDetailsResponse(BaseModel):
|
||||
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: str | None
|
||||
is_frozen: bool
|
||||
balance: float
|
||||
alias: str | None
|
||||
expiry_date: str
|
||||
days_left_message: str
|
||||
link: str | None
|
||||
cluster_name: str | None
|
||||
location_name: str | None
|
||||
tariff_id: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyUpdate(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class KeyCreateRequest(BaseModel):
|
||||
tg_id: int = Field(..., description="Telegram ID пользователя")
|
||||
cluster_id: str = Field(..., description="Имя кластера или сервера")
|
||||
tariff_id: int = Field(..., description="ID тарифа из базы данных")
|
||||
client_id: str = Field(..., description="UUID клиента (уникальный)")
|
||||
expiry_timestamp: int = Field(..., 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="Флаг триального ключа")
|
||||
@@ -0,0 +1,106 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
tg_id: int
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: Literal["success", "pending", "failed"]
|
||||
|
||||
|
||||
class PaymentResponse(PaymentBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
tg_id: int
|
||||
notification_type: str
|
||||
last_notification_time: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
tg_id: int
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
until: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
tg_id: int
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
tg_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TrackingSourceResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
type: str
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServerBase(BaseModel):
|
||||
cluster_name: str
|
||||
server_name: str
|
||||
api_url: str
|
||||
subscription_url: str | None = None
|
||||
inbound_id: str
|
||||
panel_type: str
|
||||
max_keys: int | None = None
|
||||
tariff_group: str | None = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ServerResponse(ServerBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TariffBase(BaseModel):
|
||||
name: str
|
||||
group_code: str
|
||||
duration_days: int
|
||||
price_rub: int
|
||||
traffic_limit: int | None = None
|
||||
device_limit: int | None = None
|
||||
is_active: bool = True
|
||||
subgroup_title: str | None = None
|
||||
|
||||
|
||||
class TariffResponse(TariffBase):
|
||||
id: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffUpdate(BaseModel):
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
tg_id: int
|
||||
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: str | None = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -8,58 +13,103 @@ 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)
|
||||
|
||||
|
||||
def get_git_commit_number() -> str:
|
||||
_last_check_time = 0
|
||||
_last_git_info = ""
|
||||
|
||||
|
||||
def _get_git_commit_number_uncached() -> str:
|
||||
repo_url = "https://github.com/Vladless/Solo_bot"
|
||||
cwd = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
if not os.path.isdir(os.path.join(cwd, ".git")):
|
||||
cwd = "/root/Prod/Solo_bot"
|
||||
logger.info(f"[Git] .git не найден в текущем каталоге, используем {cwd}")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["GIT_DIR"] = os.path.join(cwd, ".git")
|
||||
env["GIT_WORK_TREE"] = cwd
|
||||
|
||||
try:
|
||||
local_number = subprocess.check_output(
|
||||
["git", "rev-list", "--count", "HEAD"]
|
||||
).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()
|
||||
try:
|
||||
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()
|
||||
)
|
||||
branch = "main" if describe.startswith("v") or "release" in describe.lower() else "dev"
|
||||
except Exception:
|
||||
branch = "dev"
|
||||
|
||||
local_hash = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"]
|
||||
).decode().strip()
|
||||
except Exception:
|
||||
return "\n(Требуется обновление через CLI)"
|
||||
except Exception as e:
|
||||
logger.error(f"[Git] Ошибка при получении локального коммита: {e}")
|
||||
return f"\n(Требуется обновление через CLI (команда <code>sudo solobot</code>): {e})"
|
||||
|
||||
try:
|
||||
subprocess.check_output(["git", "fetch", "origin"], cwd=cwd, env=env)
|
||||
remote_commit = subprocess.check_output(
|
||||
["git", "ls-remote", "origin", "refs/heads/dev"]
|
||||
["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]
|
||||
).decode().strip()
|
||||
remote_number = (
|
||||
subprocess.check_output(["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env).decode().strip()
|
||||
)
|
||||
|
||||
if local_hash == remote_hash:
|
||||
logger.info("[Git] Локальная версия актуальна")
|
||||
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>)"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
return (
|
||||
f"\n(commit <a href=\"{repo_url}/commit/{local_hash}\">"
|
||||
f"#{local_number}</a> / actual commit unknown)"
|
||||
f'<a href="{repo_url}/commit/{remote_hash}">#{remote_number}</a>)'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Git] Ошибка при получении удалённого коммита: {e}")
|
||||
return "\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
|
||||
|
||||
|
||||
version = f"v4.3-Release{get_git_commit_number()}"
|
||||
@lru_cache(maxsize=1)
|
||||
def _cached_git_info() -> str:
|
||||
return _get_git_commit_number_uncached()
|
||||
|
||||
|
||||
def get_git_commit_number() -> str:
|
||||
global _last_check_time, _last_git_info
|
||||
now = time.time()
|
||||
if now - _last_check_time > 3600:
|
||||
_last_check_time = now
|
||||
_cached_git_info.cache_clear()
|
||||
_last_git_info = _cached_git_info()
|
||||
return _last_git_info
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return f"v4.4-Release{get_git_commit_number()}"
|
||||
|
||||
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
@@ -76,8 +126,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
|
||||
):
|
||||
|
||||
+172
-142
@@ -1,16 +1,23 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
|
||||
from rich.console import Console, Group
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from config import BOT_SERVICE
|
||||
|
||||
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -29,14 +36,10 @@ PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
IS_ROOT_DIR = PROJECT_DIR == "/root"
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
console.print(
|
||||
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
|
||||
)
|
||||
console.print("[bold red]КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
|
||||
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
|
||||
console.print(
|
||||
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
|
||||
)
|
||||
console.print("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
|
||||
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||
sys.exit(1)
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
@@ -44,63 +47,114 @@ 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
|
||||
|
||||
|
||||
def print_logo():
|
||||
logo = Text(
|
||||
"""
|
||||
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
|
||||
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
|
||||
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
|
||||
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
|
||||
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
|
||||
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
""",
|
||||
style="bold cyan",
|
||||
)
|
||||
console.print(logo)
|
||||
logo_lines = [
|
||||
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
|
||||
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
|
||||
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
|
||||
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
|
||||
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
|
||||
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
|
||||
]
|
||||
|
||||
with Live(refresh_per_second=10) as live:
|
||||
display = []
|
||||
for line in logo_lines:
|
||||
display.append(f"[bold cyan]{line}[/bold cyan]")
|
||||
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
|
||||
live.update(panel)
|
||||
sleep(0.07)
|
||||
|
||||
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
|
||||
|
||||
|
||||
def backup_project():
|
||||
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
|
||||
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
|
||||
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
|
||||
subprocess.run(["rm", "-rf", BACK_DIR])
|
||||
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
|
||||
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||
console.print(f"[green]Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||
|
||||
|
||||
def auto_update_cli():
|
||||
"""Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости."""
|
||||
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code != 200:
|
||||
console.print("[red]Не удалось получить обновление CLI[/red]")
|
||||
return
|
||||
|
||||
latest_text = response.text
|
||||
current_path = os.path.realpath(__file__)
|
||||
with open(current_path, encoding="utf-8") as f:
|
||||
current_text = f.read()
|
||||
|
||||
if current_text != latest_text:
|
||||
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
|
||||
with open(current_path, "w", encoding="utf-8") as f:
|
||||
f.write(latest_text)
|
||||
os.chmod(current_path, 0o755)
|
||||
console.print("[green]CLI обновлён. Перезапуск...[/green]")
|
||||
os.execv(sys.executable, [sys.executable, current_path])
|
||||
else:
|
||||
console.print("[green]CLI уже актуален[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
|
||||
|
||||
|
||||
def fix_permissions():
|
||||
"""Устанавливает корректные права на файлы проекта"""
|
||||
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
|
||||
try:
|
||||
user = os.getenv("SUDO_USER") or os.getenv("USER")
|
||||
if user:
|
||||
subprocess.run(
|
||||
["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True
|
||||
)
|
||||
"""Устанавливает корректные права на все файлы и папки проекта"""
|
||||
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
|
||||
|
||||
try:
|
||||
user = os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip()
|
||||
console.log(f"[cyan]Используем пользователь: {user}[/cyan]")
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
for dir in dirs:
|
||||
if dir == "__pycache__":
|
||||
pycache_path = os.path.join(root, dir)
|
||||
subprocess.run(["sudo", "rm", "-rf", pycache_path], check=True)
|
||||
for file in files:
|
||||
if file.endswith(".pyc"):
|
||||
pyc_path = os.path.join(root, file)
|
||||
subprocess.run(["sudo", "rm", "-f", pyc_path], check=True)
|
||||
|
||||
console.log("[blue]Изменение владельца на весь проект...[/blue]")
|
||||
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
|
||||
|
||||
console.log("[blue]Изменение прав доступа (u=rwX,go=rX)...[/blue]")
|
||||
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||
|
||||
console.print("[green]✅ Права успешно установлены[/green]")
|
||||
except subprocess.CalledProcessError as e:
|
||||
launcher_path = os.path.join(PROJECT_DIR, "cli_launcher.py")
|
||||
if os.path.exists(launcher_path):
|
||||
console.log("[blue]Установка флага +x для cli_launcher.py...[/blue]")
|
||||
subprocess.run(["chmod", "+x", launcher_path], check=True)
|
||||
|
||||
console.print(f"[green]Все права восстановлены для пользователя [bold]{user}[/bold][/green]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
|
||||
|
||||
|
||||
def install_rsync_if_needed():
|
||||
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]📦 Установка rsync...[/blue]")
|
||||
console.print("[blue]Установка rsync...[/blue]")
|
||||
os.system("sudo apt update && sudo apt install -y rsync")
|
||||
|
||||
|
||||
def clean_project_dir_safe(update_buttons=False, update_img=False):
|
||||
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
|
||||
console.print("[yellow]Очистка проекта перед обновлением...[/yellow]")
|
||||
preserved_paths = {
|
||||
os.path.join(PROJECT_DIR, "config.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
|
||||
os.path.join(PROJECT_DIR, ".git"),
|
||||
}
|
||||
|
||||
if not update_buttons:
|
||||
@@ -142,22 +196,39 @@ def install_git_if_needed():
|
||||
|
||||
|
||||
def install_dependencies():
|
||||
console.print("[blue]🔧 Установка зависимостей...[/blue]")
|
||||
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
|
||||
try:
|
||||
if not os.path.exists("venv"):
|
||||
console.print(
|
||||
"[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]"
|
||||
)
|
||||
subprocess.run("python3 -m venv venv", shell=True, check=True)
|
||||
console.print("[blue]Установка зависимостей...[/blue]")
|
||||
|
||||
python312_path = shutil.which("python3.12")
|
||||
if not python312_path:
|
||||
console.print("[red]Не найден python3.12 в системе[/red]")
|
||||
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
|
||||
sys.exit(1)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(style="green"),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
transient=True,
|
||||
) as progress:
|
||||
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
|
||||
try:
|
||||
if os.path.exists("venv"):
|
||||
shutil.rmtree("venv")
|
||||
console.print("[yellow]Удалён старый venv[/yellow]")
|
||||
|
||||
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
|
||||
|
||||
progress.update(task_id, description="Установка зависимостей...")
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
|
||||
|
||||
progress.update(task_id, description="Установка завершена")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
progress.update(task_id, description="❌ Ошибка при установке")
|
||||
console.print(f"[red]❌ Ошибка: {e}[/red]")
|
||||
|
||||
|
||||
def restart_service():
|
||||
@@ -196,40 +267,34 @@ def get_remote_version(branch="main"):
|
||||
|
||||
|
||||
def update_from_beta():
|
||||
update_cli_launcher()
|
||||
local_version = get_local_version()
|
||||
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]"
|
||||
):
|
||||
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
return
|
||||
else:
|
||||
console.print("[red]⚠️ Не удалось определить версии.[/red]")
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
if not Confirm.ask("[yellow]Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
|
||||
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
os.chdir(PROJECT_DIR)
|
||||
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
|
||||
console.print("[cyan]Клонируем временный репозиторий...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
|
||||
|
||||
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
|
||||
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
|
||||
return
|
||||
|
||||
@@ -243,37 +308,36 @@ def update_from_beta():
|
||||
exclude_options += "--exclude=handlers/buttons.py "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
|
||||
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
|
||||
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
|
||||
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
|
||||
console.print("[green]Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
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]")
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
|
||||
if not Confirm.ask("[red]Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
|
||||
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
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]
|
||||
|
||||
@@ -290,20 +354,18 @@ 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]")
|
||||
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(
|
||||
f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
|
||||
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
|
||||
console.print("[red]Начинается перезапись файлов бота![/red]")
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
|
||||
|
||||
@@ -314,12 +376,16 @@ def update_from_release():
|
||||
exclude_options += "--exclude=handlers/buttons.py "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
|
||||
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
|
||||
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
|
||||
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
|
||||
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
|
||||
@@ -327,17 +393,15 @@ def update_from_release():
|
||||
|
||||
def show_update_menu():
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print(
|
||||
"[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]"
|
||||
)
|
||||
console.print("[red]Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Источник", style="white")
|
||||
table.add_row("1", "Обновить до BETA")
|
||||
table.add_row("2", "Обновить до последнего релиза")
|
||||
table.add_row("2", "Обновить/откатить до релиза")
|
||||
table.add_row("3", "Назад в меню")
|
||||
|
||||
console.print(table)
|
||||
@@ -350,9 +414,7 @@ def show_update_menu():
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(
|
||||
title="Solobot CLI v0.1.8", title_style="bold magenta", header_style="bold blue"
|
||||
)
|
||||
table = Table(title="Solobot CLI v0.2.8", 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)")
|
||||
@@ -362,40 +424,21 @@ def show_menu():
|
||||
table.add_row("5", "Показать логи (80 строк)")
|
||||
table.add_row("6", "Показать статус")
|
||||
table.add_row("7", "Обновить Solobot")
|
||||
table.add_row("8", "Обновить CLI лаунчер")
|
||||
table.add_row("9", "Выход")
|
||||
table.add_row("8", "Выход")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def update_cli_launcher():
|
||||
"""Обновляет CLI лаунчер с dev ветки"""
|
||||
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||
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:
|
||||
f.write(response.text)
|
||||
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||
else:
|
||||
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(PROJECT_DIR)
|
||||
auto_update_cli()
|
||||
print_logo()
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask(
|
||||
"[bold blue]Введите номер действия[/bold blue]",
|
||||
choices=[str(i) for i in range(1, 10)],
|
||||
"[bold blue]👉 Введите номер действия[/bold blue]",
|
||||
choices=[str(i) for i in range(1, 9)],
|
||||
show_choices=False,
|
||||
)
|
||||
if choice == "1":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
@@ -403,39 +446,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":
|
||||
@@ -446,11 +481,6 @@ def main():
|
||||
elif choice == "7":
|
||||
show_update_menu()
|
||||
elif choice == "8":
|
||||
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
elif choice == "9":
|
||||
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
|
||||
+1
-5
@@ -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
@@ -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()
|
||||
|
||||
+3
-4
@@ -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
|
||||
)
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True, pool_size=20, max_overflow=30, pool_timeout=15)
|
||||
|
||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
+5
-1
@@ -16,6 +16,8 @@ async def store_gift_link(
|
||||
expiry_time: datetime,
|
||||
gift_link: str,
|
||||
tariff_id: int | None = None,
|
||||
is_unlimited: bool = False,
|
||||
max_usages: int | None = None,
|
||||
):
|
||||
try:
|
||||
stmt = insert(Gift).values(
|
||||
@@ -28,10 +30,12 @@ async def store_gift_link(
|
||||
created_at=datetime.utcnow(),
|
||||
is_used=False,
|
||||
tariff_id=tariff_id,
|
||||
is_unlimited=is_unlimited,
|
||||
max_usages=max_usages,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id})")
|
||||
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id}, max_usages={max_usages})")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||
|
||||
@@ -8,16 +8,14 @@ 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)
|
||||
.distinct()
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(~Payment.tg_id.in_(subquery))
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 config import USE_COUNTRY_SELECTION
|
||||
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
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
result = await session.execute(select(Server.name).where(Server.enabled is True, Server.panel_type == "3x-ui"))
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name)
|
||||
.where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
server_ids = [row[0] for row in result.fetchall()]
|
||||
if not server_ids:
|
||||
raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui")
|
||||
|
||||
server_cycle = cycle(server_ids)
|
||||
|
||||
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(server_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
|
||||
+31
-2
@@ -1,7 +1,36 @@
|
||||
from database.db import engine
|
||||
from database.models import Base
|
||||
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)
|
||||
|
||||
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(),
|
||||
)
|
||||
)
|
||||
|
||||
for tg_id in ADMIN_ID:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
if not result.scalar_one_or_none():
|
||||
session.add(
|
||||
Admin(
|
||||
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
+13
-39
@@ -18,15 +18,12 @@ async def store_key(
|
||||
server_id: str,
|
||||
remnawave_link: str = None,
|
||||
tariff_id: int = None,
|
||||
alias: str = 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(
|
||||
@@ -39,12 +36,11 @@ async def store_key(
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
)
|
||||
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 +63,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 +104,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 +131,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 +150,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 +166,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}")
|
||||
|
||||
+32
-15
@@ -1,3 +1,6 @@
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -14,6 +17,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -25,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):
|
||||
@@ -57,7 +59,7 @@ class Key(DictLikeMixin, Base):
|
||||
key = Column(String)
|
||||
server_id = Column(String)
|
||||
remnawave_link = Column(String)
|
||||
tariff_id = Column(Integer, ForeignKey("tariffs.id"))
|
||||
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
|
||||
is_frozen = Column(Boolean, default=False)
|
||||
alias = Column(String)
|
||||
notified = Column(Boolean, default=False)
|
||||
@@ -77,6 +79,7 @@ class Tariff(DictLikeMixin, Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
subgroup_title = Column(String, nullable=True)
|
||||
|
||||
|
||||
class Server(DictLikeMixin, Base):
|
||||
@@ -128,15 +131,15 @@ class CouponUsage(DictLikeMixin, Base):
|
||||
class Referral(DictLikeMixin, Base):
|
||||
__tablename__ = "referrals"
|
||||
|
||||
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
|
||||
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
|
||||
reward_issued = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class Notification(DictLikeMixin, Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), 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)
|
||||
|
||||
@@ -144,7 +147,7 @@ class Notification(DictLikeMixin, Base):
|
||||
class Gift(DictLikeMixin, Base):
|
||||
__tablename__ = "gifts"
|
||||
|
||||
gift_id = Column(String, primary_key=True)
|
||||
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
|
||||
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
|
||||
selected_months = Column(Integer)
|
||||
@@ -152,9 +155,19 @@ class Gift(DictLikeMixin, Base):
|
||||
gift_link = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_used = Column(Boolean, default=False)
|
||||
is_unlimited = Column(Boolean, default=False)
|
||||
max_usages = Column(Integer, nullable=True)
|
||||
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
|
||||
|
||||
|
||||
class GiftUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "gift_usages"
|
||||
|
||||
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
used_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ManualBan(DictLikeMixin, Base):
|
||||
__tablename__ = "manual_bans"
|
||||
|
||||
@@ -191,11 +204,15 @@ class TrackingSource(DictLikeMixin, Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Log(DictLikeMixin, Base):
|
||||
__tablename__ = "logs"
|
||||
class Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
event_type = Column(String)
|
||||
tg_id = Column(BigInteger)
|
||||
details = Column(JSON)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
token = Column(String, unique=True, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False, default="admin")
|
||||
added_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
+10
-22
@@ -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}")
|
||||
|
||||
+10
-14
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pytz import timezone
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,22 +9,22 @@ from database.models import Payment
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_payment(
|
||||
session: AsyncSession, tg_id: int, amount: float, payment_system: str
|
||||
):
|
||||
MOSCOW_TZ = timezone("Europe/Moscow")
|
||||
|
||||
|
||||
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(
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system=payment_system,
|
||||
status="success",
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=now_moscow,
|
||||
)
|
||||
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()
|
||||
@@ -33,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
@@ -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()]
|
||||
|
||||
+129
-30
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy import delete, func, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Server
|
||||
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,17 +96,124 @@ 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)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
return row
|
||||
|
||||
|
||||
async def get_server_by_name(session: AsyncSession, server_name: str) -> dict | None:
|
||||
try:
|
||||
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,
|
||||
"cluster_name": server.cluster_name,
|
||||
"server_name": server.server_name,
|
||||
"api_url": server.api_url,
|
||||
"subscription_url": server.subscription_url,
|
||||
"inbound_id": server.inbound_id,
|
||||
"panel_type": server.panel_type,
|
||||
"enabled": server.enabled,
|
||||
"max_keys": server.max_keys,
|
||||
"tariff_group": server.tariff_group,
|
||||
}
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
await session.commit()
|
||||
logger.info(f"✅ Поле {field} сервера {server_name} обновлено на {value}")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении поля {field} сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
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):
|
||||
logger.error(f"❌ Сервер с именем {new_name} уже существует")
|
||||
return False
|
||||
|
||||
stmt_server = update(Server).where(Server.server_name == old_name).values(server_name=new_name)
|
||||
await session.execute(stmt_server)
|
||||
|
||||
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
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при переименовании сервера {old_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_available_clusters(session: AsyncSession) -> list[str]:
|
||||
try:
|
||||
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
|
||||
result = await session.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении списка кластеров: {e}")
|
||||
return []
|
||||
|
||||
|
||||
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(
|
||||
(Server.cluster_name == old_cluster) & (Server.server_name != server_name)
|
||||
)
|
||||
result = await session.execute(stmt_remaining)
|
||||
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)
|
||||
await session.execute(stmt_update_keys)
|
||||
|
||||
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)
|
||||
)
|
||||
await session.execute(stmt_update)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
+23
-57
@@ -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"])
|
||||
)
|
||||
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"])
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -145,7 +111,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
|
||||
async def sum_total_payments(session: AsyncSession) -> float:
|
||||
result = await session.scalar(
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
Payment.payment_system.notin_(["referral", "coupon"])
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"])
|
||||
)
|
||||
)
|
||||
return round(float(result), 2)
|
||||
@@ -153,17 +119,17 @@ 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 = (
|
||||
select(Payment.tg_id)
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
return result.scalar()
|
||||
return result.scalar()
|
||||
|
||||
+36
-28
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, insert, select, update
|
||||
@@ -8,16 +10,36 @@ from database.models import Server, Tariff
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def get_tariffs(
|
||||
session: AsyncSession, tariff_id: int = None, group_code: str = None
|
||||
):
|
||||
def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
|
||||
if not subgroup_title:
|
||||
return ""
|
||||
|
||||
unique_key = f"{subgroup_title}:{group_code}"
|
||||
hash_object = hashlib.md5(unique_key.encode("utf-8"))
|
||||
return hash_object.hexdigest()[:8]
|
||||
|
||||
|
||||
async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group_code: str) -> str | None:
|
||||
result = await session.execute(
|
||||
select(Tariff.subgroup_title)
|
||||
.where(Tariff.group_code == group_code, Tariff.subgroup_title.isnot(None))
|
||||
.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):
|
||||
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)
|
||||
)
|
||||
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
|
||||
else:
|
||||
result = await session.execute(select(Tariff))
|
||||
|
||||
@@ -40,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 []
|
||||
|
||||
|
||||
@@ -91,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:
|
||||
@@ -115,13 +127,9 @@ 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}")
|
||||
return True
|
||||
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
|
||||
return False
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,15 +70,22 @@ 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))
|
||||
created_at_row = source_result.first()
|
||||
if not created_at_row:
|
||||
return None
|
||||
|
||||
created_at = created_at_row[0]
|
||||
|
||||
reg_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where(User.source_code == code)
|
||||
.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))
|
||||
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
@@ -92,7 +95,8 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(["coupon", "referral"]))
|
||||
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
@@ -103,7 +107,8 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(["coupon", "referral"]))
|
||||
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
+19
-24
@@ -10,6 +10,7 @@ from database.models import (
|
||||
BlockedUser,
|
||||
CouponUsage,
|
||||
Gift,
|
||||
GiftUsage,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
@@ -46,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()
|
||||
@@ -60,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()
|
||||
@@ -86,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}")
|
||||
@@ -97,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:
|
||||
@@ -179,28 +170,32 @@ async def upsert_user(
|
||||
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))
|
||||
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)
|
||||
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
|
||||
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
|
||||
await session.execute(delete(User).where(User.tg_id == tg_id))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
+9
-4
@@ -1,12 +1,17 @@
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import ADMIN_ID
|
||||
from database.db import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
try:
|
||||
return message.from_user.id in ADMIN_ID
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == event.from_user.id))
|
||||
admin = result.scalar_one_or_none()
|
||||
return admin is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +17,7 @@ from .stats import router as stats_router
|
||||
from .tariffs import router as tariffs_router
|
||||
from .users import router as users_router
|
||||
|
||||
|
||||
router = Router(name="admins_main_router")
|
||||
|
||||
router.include_routers(
|
||||
@@ -32,4 +34,5 @@ router.include_routers(
|
||||
bans_router,
|
||||
ads_router,
|
||||
tariffs_router,
|
||||
gifts_router,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -21,6 +27,7 @@ from .keyboard import (
|
||||
build_cancel_input_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -31,9 +38,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())
|
||||
@@ -50,18 +55,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>.",
|
||||
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):
|
||||
await message.answer(
|
||||
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
|
||||
reply_markup=build_cancel_input_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
code_with_prefix = f"utm_{code}"
|
||||
|
||||
try:
|
||||
@@ -90,21 +101,23 @@ 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):
|
||||
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()
|
||||
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
|
||||
items_per_page = 6
|
||||
if callback_data.code and callback_data.code.isdigit():
|
||||
current_page = int(callback_data.code)
|
||||
else:
|
||||
current_page = 1
|
||||
total_pages = (len(ads) + items_per_page - 1) // items_per_page
|
||||
reply_markup = build_ads_list_kb(ads, current_page, total_pages)
|
||||
await callback_query.message.edit_text(
|
||||
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
|
||||
)
|
||||
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())
|
||||
@@ -117,25 +130,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>?",
|
||||
@@ -151,9 +156,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(
|
||||
@@ -165,17 +168,17 @@ 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:
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
now = datetime.now(moscow_tz)
|
||||
update_time = now.strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
return (
|
||||
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
|
||||
f"📌 <b>Название:</b> {stats['name']}\n"
|
||||
@@ -187,5 +190,5 @@ def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||
f"💰 <b>Финансовая информация:</b>\n"
|
||||
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
|
||||
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
|
||||
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
|
||||
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
|
||||
)
|
||||
|
||||
@@ -12,45 +12,46 @@ 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
|
||||
|
||||
for ad in ads:
|
||||
builder.button(
|
||||
text=f"📎 {ad['name']}",
|
||||
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||
start = (current_page - 1) * items_per_page
|
||||
end = start + items_per_page
|
||||
page_ads = ads[start:end]
|
||||
|
||||
row = []
|
||||
for i, ad in enumerate(page_ads, 1):
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"📎 {ad['name']}",
|
||||
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||
)
|
||||
)
|
||||
if i % 2 == 0 or i == len(page_ads):
|
||||
builder.row(*row)
|
||||
row = []
|
||||
|
||||
pagination_buttons = []
|
||||
if current_page > 1:
|
||||
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:
|
||||
@@ -62,11 +63,16 @@ def build_ads_list_kb(
|
||||
|
||||
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🔄 Обновить",
|
||||
callback_data=AdminAdsCallback(action="view", code=code).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, Message
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import delete_user_data
|
||||
@@ -14,9 +19,14 @@ from logger import logger
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class PreemptiveBanStates(StatesGroup):
|
||||
waiting_for_preemptive_ids = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
|
||||
async def handle_bans(callback_query: CallbackQuery):
|
||||
text_ = (
|
||||
@@ -28,9 +38,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 +53,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 +66,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 +101,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 +116,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 +129,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 +141,59 @@ 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("❌ Ошибка при удалении вручную забаненных пользователей.")
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
|
||||
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
|
||||
await callback.message.edit_text(
|
||||
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
|
||||
"Пример:\n<code>123456789\n987654321</code>"
|
||||
)
|
||||
|
||||
|
||||
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
|
||||
async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
lines = message.text.strip().splitlines()
|
||||
tg_ids = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.isdigit():
|
||||
tg_ids.add(int(line))
|
||||
|
||||
if not tg_ids:
|
||||
await message.answer("❌ Не найдено ни одного корректного Telegram ID.")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
pg_insert(ManualBan)
|
||||
.values([
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"reason": "shadow",
|
||||
"banned_by": message.from_user.id,
|
||||
"until": None,
|
||||
"banned_at": now,
|
||||
}
|
||||
for tg_id in tg_ids
|
||||
])
|
||||
.on_conflict_do_update(
|
||||
index_elements=[ManualBan.tg_id],
|
||||
set_={
|
||||
"reason": "shadow",
|
||||
"until": None,
|
||||
"banned_by": message.from_user.id,
|
||||
"banned_at": now,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
|
||||
await state.clear()
|
||||
|
||||
@@ -15,16 +15,21 @@ def build_bans_kb():
|
||||
text="📛 Забаненные вручную",
|
||||
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Забанить заранее",
|
||||
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Удалить забанивших",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить вручную забаненных",
|
||||
text="🗑️ Очистить забаненных",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
|
||||
@@ -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(
|
||||
@@ -304,10 +296,16 @@ 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)))
|
||||
)
|
||||
subscription_count = result.scalar() or 0
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
|
||||
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
|
||||
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>"
|
||||
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
|
||||
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -328,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,
|
||||
@@ -341,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(
|
||||
@@ -383,47 +377,47 @@ async def handle_cluster_availability(
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
|
||||
|
||||
elif panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise Exception("Не удалось авторизоваться")
|
||||
|
||||
server_inbound_id = server.get("inbound_id")
|
||||
if not server_inbound_id:
|
||||
raise Exception("Не указан inbound_id сервера")
|
||||
|
||||
all_nodes = await remna.get_all_nodes()
|
||||
if not all_nodes:
|
||||
raise Exception("Не удалось получить список нод")
|
||||
|
||||
matching_node = None
|
||||
for node in all_nodes:
|
||||
excluded_inbounds = node.get("excludedInbounds", [])
|
||||
if server_inbound_id not in excluded_inbounds:
|
||||
matching_node = node
|
||||
break
|
||||
|
||||
if not matching_node:
|
||||
raise Exception("Нода, обслуживающая этот inbound_id, не найдена")
|
||||
|
||||
online_remna_users = matching_node.get("usersOnline", 0)
|
||||
total_online_users += online_remna_users
|
||||
result_text += (
|
||||
f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
nodes_data = await remna.get_all_nodes_with_online(
|
||||
username=REMNAWAVE_LOGIN, password=REMNAWAVE_PASSWORD, inbound_id=server_inbound_id
|
||||
)
|
||||
|
||||
if nodes_data.get("error"):
|
||||
raise Exception(nodes_data["error"])
|
||||
|
||||
online_remna_users = nodes_data["total_online"]
|
||||
total_online_users += online_remna_users
|
||||
|
||||
nodes_info = nodes_data["nodes"]
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
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,
|
||||
@@ -474,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,
|
||||
@@ -536,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}",
|
||||
@@ -552,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,
|
||||
@@ -571,7 +559,7 @@ async def handle_sync_cluster(
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
).where(Key.server_id == cluster_name)
|
||||
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
|
||||
)
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
@@ -594,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"])
|
||||
@@ -604,22 +590,21 @@ async def handle_sync_cluster(
|
||||
raise Exception("Не удалось авторизоваться в Remnawave")
|
||||
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = None
|
||||
hwid_limit = 0
|
||||
if key["tariff_id"]:
|
||||
tariff = await session.get(Tariff, key["tariff_id"])
|
||||
if tariff:
|
||||
traffic_limit_bytes = int(tariff.traffic_limit * 1024**3)
|
||||
if tariff.traffic_limit is not None:
|
||||
traffic_limit_bytes = int(tariff.traffic_limit * 1024**3)
|
||||
else:
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = tariff.device_limit
|
||||
else:
|
||||
logger.warning(
|
||||
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"],
|
||||
@@ -632,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(
|
||||
@@ -656,16 +636,13 @@ async def handle_sync_cluster(
|
||||
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(
|
||||
@@ -679,12 +656,10 @@ async def handle_sync_cluster(
|
||||
remnawave_link=key["remnawave_link"],
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
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}",
|
||||
@@ -700,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)
|
||||
@@ -721,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,
|
||||
@@ -766,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
|
||||
|
||||
@@ -792,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:
|
||||
@@ -818,12 +783,11 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
reset_traffic=False,
|
||||
)
|
||||
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>."
|
||||
@@ -838,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,
|
||||
@@ -864,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="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
@@ -887,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()
|
||||
|
||||
@@ -901,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()
|
||||
@@ -928,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"),
|
||||
@@ -939,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,
|
||||
@@ -984,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="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||
@@ -1023,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(
|
||||
@@ -1040,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()
|
||||
|
||||
@@ -1054,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"),
|
||||
@@ -1066,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]
|
||||
@@ -1077,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(
|
||||
@@ -1112,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]
|
||||
@@ -1124,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(
|
||||
@@ -1163,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]
|
||||
@@ -1188,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:
|
||||
@@ -1209,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(
|
||||
@@ -1223,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("❌ Произошла ошибка при установке тарифной группы.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .gifts_handler import router
|
||||
@@ -0,0 +1,275 @@
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@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.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())
|
||||
return
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
grouped_tariffs[t.subgroup_title].append(t)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
if t.duration_days % 30 == 0:
|
||||
duration_text = format_months(t.duration_days // 30)
|
||||
else:
|
||||
duration_text = format_days(t.duration_days)
|
||||
|
||||
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")
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=subgroup,
|
||||
callback_data=f"admin_gift_subgroup|{subgroup_hash}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_gift_subgroup|"))
|
||||
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
subgroup_hash = callback.data.split("|", 1)[1]
|
||||
|
||||
subgroup = await find_subgroup_by_hash(session, subgroup_hash, "gifts")
|
||||
if not subgroup:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
filtered = [t for t in tariffs if t.subgroup_title == subgroup]
|
||||
if not filtered:
|
||||
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
if t.duration_days % 30 == 0:
|
||||
duration_text = format_months(t.duration_days // 30)
|
||||
else:
|
||||
duration_text = format_days(t.duration_days)
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{t.name} – {duration_text}",
|
||||
callback_data=f"admin_gift_select|{t.id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ADMIN_GIFT_SUBGROUP] Ошибка при отображении подгруппы: {e}")
|
||||
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_gift_select|"))
|
||||
async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await state.set_state(GiftCreationState.waiting_for_limit_input_or_unlimited)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="🔙 Назад", callback_data="admin_gift_create")
|
||||
await callback.message.edit_text(
|
||||
"🔢 Введите максимальное количество активаций подарка:", 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:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное положительное число.")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
data["max_usages"] = max_usages
|
||||
await state.clear()
|
||||
await finalize_gift(message, session, bot, data, is_unlimited=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_gifts_all")
|
||||
async def show_gifts_page(callback: CallbackQuery, session: AsyncSession):
|
||||
await show_gift_list(callback, session, page=1)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gifts_page|"))
|
||||
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
|
||||
page = int(callback.data.split("|")[1])
|
||||
await show_gift_list(callback, session, page)
|
||||
|
||||
|
||||
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
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())
|
||||
return
|
||||
|
||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
||||
|
||||
builder.inline_keyboard.extend(keyboard.inline_keyboard)
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not gifts:
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
||||
|
||||
for row in keyboard.inline_keyboard:
|
||||
builder.row(*row)
|
||||
|
||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gift_view|"))
|
||||
async def view_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
|
||||
if not gift:
|
||||
await callback.message.edit_text("❌ Подарок не найден.")
|
||||
return
|
||||
|
||||
usage_result = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
|
||||
)
|
||||
used_count = usage_result.scalar_one()
|
||||
usage_text = f"{used_count}/{gift.max_usages}" if gift.max_usages else "∞"
|
||||
|
||||
duration_days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
if duration_days % 30 == 0:
|
||||
duration_text = format_months(duration_days // 30)
|
||||
else:
|
||||
duration_text = format_days(duration_days)
|
||||
|
||||
text = (
|
||||
f"🎁 <b>Подарок</b>\n"
|
||||
f"ID: <code>{gift.gift_id}</code>\n"
|
||||
f"Срок: <b>{duration_text}</b>\n"
|
||||
f"Активаций: <b>{usage_text}</b>\n"
|
||||
f"Истекает: <i>{gift.expiry_time.strftime('%d.%m.%Y')}</i>\n"
|
||||
f"<b>Ссылка для активации:</b>\n<blockquote>{gift.gift_link}</blockquote>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
|
||||
builder.button(text="🔙 Назад", callback_data="admin_gifts_all")
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gift_delete|"))
|
||||
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.execute(delete(Gift).where(Gift.gift_id == gift_id))
|
||||
await session.commit()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад к списку", callback_data="admin_gifts_all")
|
||||
|
||||
await callback.message.edit_text("✅ Подарок удалён.", reply_markup=builder.as_markup())
|
||||
@@ -0,0 +1,69 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database.models import Gift, Tariff
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days, format_months, 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_gifts_all"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="admin"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for tariff in tariffs:
|
||||
builder.button(
|
||||
text=f"{tariff.name} — {tariff.duration_days // 30} мес.",
|
||||
callback_data=f"admin_gift_confirm|{tariff.id}",
|
||||
)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
row = []
|
||||
|
||||
for i, gift in enumerate(gifts):
|
||||
if gift.selected_months > 0:
|
||||
duration_text = format_months(gift.selected_months)
|
||||
else:
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
|
||||
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
|
||||
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"gift_view|{gift.gift_id}",
|
||||
)
|
||||
)
|
||||
|
||||
if len(row) == 2 or i == len(gifts) - 1:
|
||||
builder.row(*row)
|
||||
row = []
|
||||
|
||||
nav = []
|
||||
if page > 1:
|
||||
nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"gifts_page|{page - 1}"))
|
||||
if len(gifts) == 10:
|
||||
nav.append(InlineKeyboardButton(text="➡️ Далее", callback_data=f"gifts_page|{page + 1}"))
|
||||
if nav:
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -6,11 +6,18 @@ from middlewares import maintenance
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_management_kb() -> InlineKeyboardMarkup:
|
||||
def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.button(
|
||||
text="👑 Управление админами",
|
||||
callback_data=AdminPanelCallback(action="admins").pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="💾 Создать резервную копию",
|
||||
callback_data=AdminPanelCallback(action="backups").pack(),
|
||||
text="🗄 Управление БД",
|
||||
callback_data=AdminPanelCallback(action="database").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📛 Управление банами",
|
||||
@@ -21,18 +28,18 @@ def build_management_kb() -> InlineKeyboardMarkup:
|
||||
callback_data=AdminPanelCallback(action="restart").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🌐 Сменить домен",
|
||||
text="🌐 Сменить домен подписок",
|
||||
callback_data=AdminPanelCallback(action="change_domain").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔑 Восстановить пробники",
|
||||
callback_data=AdminPanelCallback(action="restore_trials").pack(),
|
||||
)
|
||||
maintenance_text = (
|
||||
"🛠️ Выключить тех. работы"
|
||||
if maintenance.maintenance_mode
|
||||
else "🛠️ Включить тех. работы"
|
||||
builder.button(
|
||||
text="📤 Загрузить файл",
|
||||
callback_data=AdminPanelCallback(action="upload_file").pack(),
|
||||
)
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
||||
builder.button(
|
||||
text=maintenance_text,
|
||||
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||
@@ -41,3 +48,114 @@ def build_management_kb() -> InlineKeyboardMarkup:
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_database_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="💾 Создать резервную копию",
|
||||
callback_data=AdminPanelCallback(action="backups").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Восстановить БД из бэкапа",
|
||||
callback_data=AdminPanelCallback(action="restore_db").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📤 Получить данные БД из панели",
|
||||
callback_data=AdminPanelCallback(action="export_db").pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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="🧩 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()
|
||||
|
||||
|
||||
def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for tg_id, role in admins:
|
||||
builder.button(
|
||||
text=f"🧑 {tg_id} ({role})", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
|
||||
)
|
||||
|
||||
builder.button(text="➕ Добавить админа", callback_data=AdminPanelCallback(action="add_admin").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
|
||||
|
||||
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
|
||||
|
||||
if role == "superadmin":
|
||||
builder.button(
|
||||
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
|
||||
)
|
||||
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
|
||||
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
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
|
||||
from dateutil import parser
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -20,19 +50,40 @@ class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
|
||||
)
|
||||
async def handle_management(callback_query: CallbackQuery):
|
||||
class Import3xuiStates(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class FileUploadState(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class DatabaseState(StatesGroup):
|
||||
waiting_for_backup_file = State()
|
||||
|
||||
|
||||
class AdminState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(),
|
||||
reply_markup=build_management_kb(admin.role),
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -42,14 +93,9 @@ 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}'"
|
||||
)
|
||||
|
||||
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
@@ -60,13 +106,18 @@ async def process_new_domain(
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
|
||||
|
||||
try:
|
||||
stmt = (
|
||||
update(Key)
|
||||
.where(~Key.key.startswith(new_domain_url))
|
||||
.values(key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url))
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
@@ -80,8 +131,8 @@ async def process_new_domain(
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.execute(select(Key.key).limit(1))
|
||||
example = sample.scalar()
|
||||
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
|
||||
example = sample.fetchone()
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
@@ -94,10 +145,544 @@ async def process_new_domain(
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery):
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
|
||||
async def show_admins(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Admin.tg_id, Admin.role))
|
||||
admins = result.all()
|
||||
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
|
||||
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
await state.set_state(AdminState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.message(AdminState.waiting_for_tg_id)
|
||||
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
|
||||
try:
|
||||
tg_id = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
if result.scalar_one_or_none():
|
||||
await message.answer("⚠️ Такой админ уже существует.")
|
||||
else:
|
||||
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
|
||||
await session.commit()
|
||||
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
|
||||
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
role = admin or "moderator"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
|
||||
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
token = Admin.generate_token()
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
admin.token = token_hash
|
||||
await session.commit()
|
||||
|
||||
msg = await callback.message.edit_text(
|
||||
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
|
||||
f"<code>{token}</code>\n\n"
|
||||
f"⚠️ Это сообщение исчезнет через 5 минут.",
|
||||
reply_markup=build_token_result_kb(token),
|
||||
)
|
||||
|
||||
await sleep(300)
|
||||
try:
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
|
||||
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
await callback.message.edit_text(
|
||||
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
|
||||
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
try:
|
||||
_, tg_id_str, role = callback_data.action.split("|")
|
||||
tg_id = int(tg_id_str)
|
||||
if role not in ("superadmin", "moderator"):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await callback.message.edit_text("❌ Неверный формат.")
|
||||
return
|
||||
|
||||
if tg_id == callback.from_user.id:
|
||||
await callback.message.edit_text(
|
||||
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
admin.role = role
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
|
||||
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
|
||||
async def handle_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text="🗄 <b>Управление базой данных</b>",
|
||||
reply_markup=build_database_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
|
||||
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
|
||||
"⚠️ Все текущие данные будут перезаписаны.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(DatabaseState.waiting_for_backup_file)
|
||||
|
||||
|
||||
@router.message(DatabaseState.waiting_for_backup_file)
|
||||
async def restore_database(message: Message, state: FSMContext, bot: Bot):
|
||||
document = message.document
|
||||
|
||||
if not document or not document.file_name.endswith(".sql"):
|
||||
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
|
||||
return
|
||||
|
||||
try:
|
||||
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
await bot.download(document, destination=tmp_path)
|
||||
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
|
||||
|
||||
is_custom_dump = False
|
||||
with open(tmp_path, "rb") as f:
|
||||
signature = f.read(5)
|
||||
if signature == b"PGDMP":
|
||||
is_custom_dump = True
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-u",
|
||||
"postgres",
|
||||
"psql",
|
||||
"-d",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
logger.info("[Restore] База данных пересоздана")
|
||||
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
if is_custom_dump:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pg_restore",
|
||||
f"--dbname={DB_NAME}",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"--no-owner",
|
||||
"--exit-on-error",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-d",
|
||||
DB_NAME,
|
||||
"-f",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
|
||||
)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"✅ База данных восстановлена.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
|
||||
await state.clear()
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
|
||||
await message.answer(
|
||||
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
|
||||
async def handle_export_db(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
"📤 Выберите панель, с которой требуется получить данные:\n\n"
|
||||
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
|
||||
reply_markup=build_export_db_sources_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
|
||||
async def back_to_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
|
||||
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.answer()
|
||||
|
||||
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
||||
servers = result.scalars().all()
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных Remnawave-серверов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
server = servers[0]
|
||||
|
||||
api = RemnawaveAPI(base_url=server.api_url)
|
||||
|
||||
users = await api.get_all_users_time(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"📭 На панели нет клиентов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
||||
|
||||
added_users = await import_remnawave_users(session, users)
|
||||
|
||||
server_id = server.cluster_name or server.server_name
|
||||
|
||||
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
||||
|
||||
preview = ""
|
||||
for i, user in enumerate(users[:3], 1):
|
||||
email = user.get("email") or user.get("username") or "-"
|
||||
expire = user.get("expireAt", "")[:10]
|
||||
preview += f"{i}. {email} — до {expire}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
|
||||
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
|
||||
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
|
||||
f"<b>Первые 3:</b>\n{preview}",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
if not tg_id:
|
||||
continue
|
||||
|
||||
exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
if exists.scalar():
|
||||
continue
|
||||
|
||||
try:
|
||||
new_user = User(
|
||||
tg_id=tg_id,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code=None,
|
||||
is_bot=False,
|
||||
balance=0.0,
|
||||
trial=1,
|
||||
source_code=None,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
session.add(new_user)
|
||||
added += 1
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
await session.commit()
|
||||
return added
|
||||
|
||||
|
||||
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
client_id = user.get("uuid")
|
||||
email = user.get("email") or user.get("username")
|
||||
remnawave_link = user.get("subscriptionUrl")
|
||||
expire_at = user.get("expireAt")
|
||||
created_at = user.get("createdAt")
|
||||
|
||||
if not tg_id or not client_id:
|
||||
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
||||
continue
|
||||
|
||||
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if exists_stmt.scalar():
|
||||
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
||||
continue
|
||||
|
||||
try:
|
||||
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
||||
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_ts,
|
||||
expiry_time=expire_ts,
|
||||
key="",
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=None,
|
||||
is_frozen=False,
|
||||
alias=None,
|
||||
notified=False,
|
||||
notified_24h=False,
|
||||
)
|
||||
session.add(new_key)
|
||||
added += 1
|
||||
|
||||
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
||||
return added
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
|
||||
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
|
||||
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
|
||||
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
|
||||
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(Import3xuiStates.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(Import3xuiStates.waiting_for_file, F.document)
|
||||
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
|
||||
file = message.document
|
||||
|
||||
if not file.file_name.endswith(".db"):
|
||||
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
|
||||
return
|
||||
|
||||
file_path = f"/tmp/{file.file_name}"
|
||||
await message.bot.download(file, destination=file_path)
|
||||
|
||||
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
|
||||
|
||||
try:
|
||||
from database.importer import import_keys_from_3xui_db
|
||||
|
||||
imported, skipped = await import_keys_from_3xui_db(file_path, session)
|
||||
|
||||
await processing_message.edit_text(
|
||||
f"✅ Восстановление завершено:\n"
|
||||
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
|
||||
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
|
||||
reply_markup=build_post_import_kb(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Import 3x-ui] Ошибка: {e}")
|
||||
await processing_message.edit_text(
|
||||
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
|
||||
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
||||
|
||||
result = await session.execute(select(Key.tg_id, Key.email))
|
||||
keys = result.all()
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for tg_id, email in keys:
|
||||
try:
|
||||
await update_subscription(tg_id=tg_id, email=email, session=session)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
||||
failed += 1
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
|
||||
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📤 <b>Загрузка файла</b>\n\n"
|
||||
"Вы можете заменить файл в корневой директории бота.\n\n"
|
||||
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
|
||||
"как у уже существующего файла. Он будет автоматически заменён.",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.set_state(FileUploadState.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(FileUploadState.waiting_for_file, F.document)
|
||||
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
||||
document = message.document
|
||||
file_name = document.file_name
|
||||
|
||||
if not file_name or "." not in file_name:
|
||||
await message.answer("❌ У файла должно быть имя с расширением.")
|
||||
return
|
||||
|
||||
dest_path = os.path.abspath(f"./{file_name}")
|
||||
|
||||
try:
|
||||
await message.bot.download(document, destination=dest_path)
|
||||
await message.answer(
|
||||
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
|
||||
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
||||
await message.answer(
|
||||
f"❌ Не удалось сохранить файл: {e}",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -17,49 +17,51 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||
super().__init__(**data)
|
||||
|
||||
|
||||
def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="👤 Поиск пользователя",
|
||||
callback_data=AdminPanelCallback(action="search_user").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔑 Поиск по названию ключа",
|
||||
text="🔑 Поиск по подписке",
|
||||
callback_data=AdminPanelCallback(action="search_key").pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🖥️ Управление серверами",
|
||||
callback_data=AdminPanelCallback(action="clusters").pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Управление",
|
||||
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()),
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.button(
|
||||
text="🤖 Управление ботом",
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
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()
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=AdminPanelCallback(action="stats").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📈 Аналитика",
|
||||
callback_data=AdminPanelCallback(action="ads").pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text=MAIN_MENU,
|
||||
callback_data="profile",
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=AdminPanelCallback(action="stats").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()
|
||||
),
|
||||
)
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1, 1, 2, 2, 1, 2, 1)
|
||||
builder.adjust(1, 1, 1, 2, 2, 1, 2 if admin_role == "superadmin" else 0, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -89,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())
|
||||
|
||||
@@ -3,34 +3,41 @@ from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import version
|
||||
from bot import get_version
|
||||
from database.models import Admin
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
|
||||
|
||||
await state.clear()
|
||||
|
||||
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)
|
||||
|
||||
if callback_query.message.text:
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_panel_kb(),
|
||||
reply_markup=markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.warning(
|
||||
"🔄 Попытка редактировать сообщение без изменений — пропущено."
|
||||
)
|
||||
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
@@ -41,19 +48,27 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=text,
|
||||
reply_markup=build_panel_kb(),
|
||||
reply_markup=markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
await handle_admin_message(callback_query.message, state)
|
||||
async def handle_admin_callback_query_simple(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await handle_admin_callback_query(callback_query, state, session)
|
||||
|
||||
|
||||
@router.message(Command("admin"), IsAdminFilter())
|
||||
async def handle_admin_message(message: Message, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
|
||||
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
|
||||
|
||||
await state.clear()
|
||||
await message.answer(text=text, reply_markup=build_panel_kb())
|
||||
|
||||
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 +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)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import asyncio
|
||||
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
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -14,11 +19,104 @@ 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()
|
||||
|
||||
|
||||
async def send_broadcast_batch(bot, messages, batch_size=15):
|
||||
results = []
|
||||
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
tasks = []
|
||||
|
||||
for msg in batch:
|
||||
tg_id = msg["tg_id"]
|
||||
text = msg["text"]
|
||||
photo = msg.get("photo")
|
||||
keyboard = msg.get("keyboard")
|
||||
|
||||
if photo:
|
||||
task = bot.send_photo(
|
||||
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
task = bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
tasks.append(task)
|
||||
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in batch_results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"❌ Ошибка отправки: {result}")
|
||||
results.append(False)
|
||||
else:
|
||||
results.append(True)
|
||||
|
||||
if i + batch_size < len(messages):
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
preview = State()
|
||||
|
||||
|
||||
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
|
||||
if "BUTTONS:" not in text:
|
||||
return text, 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()]
|
||||
|
||||
for line in button_lines:
|
||||
try:
|
||||
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:
|
||||
callback_data = button_data["callback"]
|
||||
if len(callback_data) > 64:
|
||||
logger.warning(f"Callback слишком длинный: {callback_data}")
|
||||
continue
|
||||
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
|
||||
elif "url" in button_data:
|
||||
url = button_data["url"]
|
||||
button = InlineKeyboardButton(text=text_btn, url=url)
|
||||
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
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -26,10 +124,16 @@ class AdminSender(StatesGroup):
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Выберите группу пользователей для рассылки:",
|
||||
reply_markup=build_sender_kb(),
|
||||
)
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Выберите группу пользователей для рассылки:",
|
||||
reply_markup=build_sender_kb(),
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -40,7 +144,22 @@ async def handle_sender_callback_text(
|
||||
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите текст сообщения для рассылки:",
|
||||
text=(
|
||||
"✍️ Введите текст сообщения для рассылки\n\n"
|
||||
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
|
||||
"Вы можете отправить:\n"
|
||||
"• Только <b>текст</b>\n"
|
||||
"• Только <b>картинку</b>\n"
|
||||
"• <b>Текст + картинку</b>\n"
|
||||
"• <b>Сообщение + кнопки</b> (см. формат ниже)\n\n"
|
||||
"<b>📋 Пример формата кнопок:</b>\n"
|
||||
"<code>Ваше сообщение</code>\n\n"
|
||||
"<code>BUTTONS:</code>\n"
|
||||
'<code>{"text": "👤 Личный кабинет", "callback": "profile"}</code>\n'
|
||||
'<code>{"text": "➕ Купить подписку", "callback": "buy"}</code>\n'
|
||||
'<code>{"text": "🎁 Забрать купон", "url": "https://t.me/cupons"}</code>\n'
|
||||
'<code>{"text": "📢 Канал", "url": "https://t.me/channel"}</code>'
|
||||
),
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
|
||||
@@ -62,31 +181,62 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSe
|
||||
|
||||
|
||||
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||
async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
text_message = message.html_text if message.text else None
|
||||
async def handle_message_input(message: Message, state: FSMContext):
|
||||
original_text = message.html_text or message.text or message.caption or ""
|
||||
photo = message.photo[-1].file_id if message.photo else None
|
||||
photo_url = (
|
||||
message.caption
|
||||
if message.photo and message.caption and message.caption.startswith("http")
|
||||
else None
|
||||
)
|
||||
|
||||
if not text_message and message.caption:
|
||||
text_message = message.caption
|
||||
clean_text, keyboard = parse_message_buttons(original_text)
|
||||
|
||||
if not text_message and not photo and not photo_url:
|
||||
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
|
||||
max_len = 1024 if photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
await message.answer(
|
||||
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
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)
|
||||
else:
|
||||
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"),
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_message", IsAdminFilter())
|
||||
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
text_message = data.get("text")
|
||||
photo = data.get("photo")
|
||||
keyboard_data = data.get("keyboard")
|
||||
send_to = data.get("type", "all")
|
||||
cluster_name = data.get("cluster_name")
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
|
||||
query = None
|
||||
keyboard = None
|
||||
if keyboard_data:
|
||||
try:
|
||||
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления клавиатуры: {e}")
|
||||
|
||||
query = None
|
||||
if send_to == "subscribed":
|
||||
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
|
||||
|
||||
elif send_to == "unsubscribed":
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
@@ -101,20 +251,16 @@ async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
)
|
||||
)
|
||||
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))
|
||||
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Key, User.tg_id == Key.tg_id)
|
||||
.join(Server, Key.server_id == Server.cluster_name)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
)
|
||||
|
||||
elif send_to == "hotleads":
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
@@ -123,7 +269,6 @@ async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
.where(Payment.status == "success")
|
||||
.where(~User.tg_id.in_(subquery))
|
||||
)
|
||||
|
||||
else:
|
||||
query = select(distinct(User.tg_id))
|
||||
|
||||
@@ -133,27 +278,18 @@ async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
await message.answer(
|
||||
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||
)
|
||||
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
|
||||
|
||||
messages = []
|
||||
for tg_id in tg_ids:
|
||||
try:
|
||||
if photo or photo_url:
|
||||
await message.bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=photo or photo_url,
|
||||
caption=text_message,
|
||||
)
|
||||
else:
|
||||
await message.bot.send_message(
|
||||
chat_id=tg_id, text=text_message, parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
|
||||
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
|
||||
messages.append(message_data)
|
||||
|
||||
await message.answer(
|
||||
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15)
|
||||
|
||||
success_count = sum(1 for result in results if result)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=(
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
@@ -163,3 +299,12 @@ async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
|
||||
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
"🚫 Рассылка отменена.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
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(
|
||||
@@ -37,8 +32,8 @@ def build_manage_server_kb(
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="✏️ Сменить название",
|
||||
callback_data=AdminServerCallback(action="rename", data=server_name).pack(),
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"edit_server|{server_name}",
|
||||
)
|
||||
|
||||
builder.button(
|
||||
@@ -48,3 +43,61 @@ def build_manage_server_kb(
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKeyboardMarkup:
|
||||
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"))
|
||||
|
||||
if server_data.get("subscription_url"):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
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="⬅️ Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
|
||||
return 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}")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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}"))
|
||||
|
||||
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}")]]
|
||||
)
|
||||
|
||||
@@ -8,11 +8,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,
|
||||
)
|
||||
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
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_cancel_edit_kb,
|
||||
build_cluster_selection_kb,
|
||||
build_edit_server_fields_kb,
|
||||
build_manage_server_kb,
|
||||
build_panel_type_selection_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -21,9 +36,12 @@ class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
|
||||
)
|
||||
class ServerEditState(StatesGroup):
|
||||
choosing_field = State()
|
||||
editing_value = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_server_manage(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
@@ -33,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),
|
||||
)
|
||||
|
||||
@@ -46,30 +59,43 @@ async def handle_server_manage(
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
panel_type = server.get("panel_type") or "не указан"
|
||||
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))
|
||||
subscription_count = result.scalar() or 0
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {api_url}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n"
|
||||
f"<blockquote>"
|
||||
f"🗂 Кластер: <b>{cluster_name}</b>\n"
|
||||
f"📡 API URL: <b>{api_url}</b>\n"
|
||||
)
|
||||
|
||||
if subscription_url:
|
||||
text += f"🌐 Subscription URL: <b>{subscription_url}</b>\n"
|
||||
|
||||
text += (
|
||||
f"🔑 Inbound ID: <b>{inbound_id}</b>\n"
|
||||
f"⚙️ Тип панели: <b>{panel_type}</b>\n"
|
||||
f"📈 Лимит ключей: <b>{limit_display}</b>\n"
|
||||
)
|
||||
|
||||
if subscription_count > 0:
|
||||
text += f"🔑 Подписок на сервере: <b>{subscription_count}</b>\n"
|
||||
|
||||
text += "</blockquote>"
|
||||
|
||||
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,
|
||||
@@ -80,7 +106,7 @@ async def process_callback_delete_server(
|
||||
|
||||
server_name = callback_data.data
|
||||
|
||||
servers_dict = await get_servers(session)
|
||||
servers_dict = await get_servers(session, include_enabled=True)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers_dict.items():
|
||||
if any(s["server_name"] == server_name for s in server_list):
|
||||
@@ -101,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()
|
||||
|
||||
@@ -126,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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -146,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 = (
|
||||
@@ -192,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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -205,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(
|
||||
@@ -215,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(
|
||||
@@ -226,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,
|
||||
@@ -239,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),
|
||||
)
|
||||
|
||||
@@ -275,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)
|
||||
@@ -296,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:
|
||||
@@ -309,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),
|
||||
)
|
||||
|
||||
@@ -345,11 +317,139 @@ 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()
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
|
||||
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await state.clear()
|
||||
|
||||
server_data = await get_server_by_name(session, server_name)
|
||||
if not server_data:
|
||||
await callback.message.edit_text("❌ Сервер не найден.")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
|
||||
reply_markup=build_edit_server_fields_kb(server_name, server_data),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
|
||||
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, server_name, field = callback.data.split("|")
|
||||
|
||||
if field == "cluster_name":
|
||||
clusters = await get_available_clusters(session)
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
|
||||
reply_markup=build_cluster_selection_kb(server_name, clusters),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name, field=field)
|
||||
await state.set_state(ServerEditState.editing_value)
|
||||
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID",
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||
reply_markup=build_cancel_edit_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
|
||||
async def select_panel_type(callback: CallbackQuery):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
|
||||
reply_markup=build_panel_type_selection_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
|
||||
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, panel_type = callback.data.split("|")
|
||||
|
||||
success = await update_server_field(session, server_name, "panel_type", panel_type)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
|
||||
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, new_cluster = callback.data.split("|")
|
||||
|
||||
success = await update_server_cluster(session, server_name, new_cluster)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении кластера")
|
||||
|
||||
|
||||
@router.message(ServerEditState.editing_value, IsAdminFilter())
|
||||
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
server_name = data["server_name"]
|
||||
field = data["field"]
|
||||
value = message.text.strip()
|
||||
|
||||
if field == "server_name":
|
||||
success = await update_server_name_with_keys(session, server_name, value)
|
||||
if success:
|
||||
server_name = value
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
|
||||
return
|
||||
else:
|
||||
success = await update_server_field(session, server_name, field, value)
|
||||
|
||||
if success:
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID",
|
||||
}
|
||||
|
||||
await message.answer(
|
||||
f"✅ {field_names.get(field, field).capitalize()} изменено",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении поля")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,31 +40,53 @@ from utils.csv_export import (
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_stats_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
|
||||
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
today = now.date()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
now = datetime.now(moscow_tz)
|
||||
today = now.date()
|
||||
|
||||
total_users = await count_total_users(session)
|
||||
users_updated_today = await count_users_updated_today(session, today)
|
||||
registrations_today = await count_users_registered_since(session, today)
|
||||
registrations_yesterday = await count_users_registered_between(
|
||||
session, today - timedelta(days=1), today
|
||||
)
|
||||
registrations_week = await count_users_registered_since(
|
||||
session, today - timedelta(days=today.weekday())
|
||||
)
|
||||
registrations_month = await count_users_registered_since(
|
||||
session, today.replace(day=1)
|
||||
)
|
||||
last_month_start = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||
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)
|
||||
|
||||
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, today.replace(day=1)
|
||||
session, last_month_start_utc, last_month_end_utc
|
||||
)
|
||||
|
||||
total_keys = await count_total_keys(session)
|
||||
@@ -86,7 +108,7 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
||||
|
||||
tariff_stats_text = ""
|
||||
duration_buckets = Counter()
|
||||
now_ts = int(datetime.utcnow().timestamp() * 1000)
|
||||
now_ts = int(now.timestamp() * 1000)
|
||||
|
||||
for key in no_tariff_keys:
|
||||
duration_days = round((key["expiry_time"] - now_ts) / (1000 * 60 * 60 * 24))
|
||||
@@ -107,49 +129,39 @@ 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)
|
||||
total_payments_today = await sum_payments_since(session, today_start.replace(tzinfo=None))
|
||||
total_payments_yesterday = await sum_payments_between(
|
||||
session, today - timedelta(days=1), today
|
||||
session, yesterday_start.replace(tzinfo=None), yesterday_end.replace(tzinfo=None)
|
||||
)
|
||||
total_payments_week = await sum_payments_since(
|
||||
session, today - timedelta(days=today.weekday())
|
||||
)
|
||||
total_payments_month = await sum_payments_since(session, today.replace(day=1))
|
||||
total_payments_week = await sum_payments_since(session, week_start.replace(tzinfo=None))
|
||||
total_payments_month = await sum_payments_since(session, month_start.replace(tzinfo=None))
|
||||
total_payments_last_month = await sum_payments_between(
|
||||
session, last_month_start, today.replace(day=1)
|
||||
session, last_month_start.replace(tzinfo=None), last_month_end.replace(tzinfo=None)
|
||||
)
|
||||
total_payments_all_time = await sum_total_payments(session)
|
||||
hot_leads_count = await count_hot_leads(session)
|
||||
|
||||
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||
update_time = now.strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
stats_message = (
|
||||
f"📊 <b>Статистика проекта</b>\n\n"
|
||||
@@ -187,79 +199,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)
|
||||
@@ -267,26 +255,28 @@ async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSe
|
||||
|
||||
async def send_daily_stats_report(session: AsyncSession):
|
||||
try:
|
||||
now_moscow = datetime.now(pytz.timezone("Europe/Moscow"))
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
now_moscow = datetime.now(moscow_tz)
|
||||
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
|
||||
|
||||
today = (
|
||||
pytz.timezone("Europe/Moscow")
|
||||
.localize(datetime.combine((now_moscow - timedelta(days=1)).date(), datetime.min.time()))
|
||||
.astimezone(pytz.UTC)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
report_date = now_moscow.date() - timedelta(days=1)
|
||||
|
||||
registrations_today = await count_users_registered_since(session, today)
|
||||
payments_today = await sum_payments_since(session, today)
|
||||
start = moscow_tz.localize(datetime.combine(report_date, datetime.min.time()))
|
||||
end = moscow_tz.localize(datetime.combine(report_date + timedelta(days=1), datetime.min.time()))
|
||||
|
||||
start_utc = start.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||
end_utc = end.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||
|
||||
registrations_today = await count_users_registered_between(session, start_utc, end_utc)
|
||||
payments_today = await sum_payments_between(session, start.replace(tzinfo=None), end.replace(tzinfo=None))
|
||||
active_keys = await count_active_keys(session)
|
||||
|
||||
text = (
|
||||
"🗓️ <b>Сводка за день</b>\n\n"
|
||||
f"🗓️ <b>Сводка за {report_date.strftime('%d.%m.%Y')} с 00:00 до 23:59 МСК</b>\n\n"
|
||||
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
|
||||
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
|
||||
f"🔐 Активных ключей: <b>{active_keys}</b>\n\n"
|
||||
f"⏱️ <i>{update_time} МСК</i>"
|
||||
f"⏱️ <i>Отчёт сгенерирован: {update_time} МСК</i>"
|
||||
)
|
||||
|
||||
for admin_id in ADMIN_ID:
|
||||
|
||||
@@ -1,7 +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 database.tariffs import create_subgroup_hash
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
@@ -23,36 +27,34 @@ 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")]]
|
||||
)
|
||||
|
||||
|
||||
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for group in groups:
|
||||
builder.button(
|
||||
text=group,
|
||||
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
|
||||
row = []
|
||||
|
||||
for i, group in enumerate(groups):
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=group,
|
||||
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
|
||||
)
|
||||
)
|
||||
if len(row) == 2 or i == len(groups) - 1:
|
||||
builder.row(*row)
|
||||
row = []
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminPanelCallback(action="tariffs").pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -66,7 +68,21 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
|
||||
else:
|
||||
group_code = "unknown"
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for t in tariffs:
|
||||
subgroup = t.get("subgroup_title")
|
||||
grouped[subgroup].append(t)
|
||||
|
||||
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}"
|
||||
)
|
||||
)
|
||||
|
||||
for t in grouped.get(None, []):
|
||||
title = f"{t['name']} — {t['price_rub']}₽"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -82,9 +98,12 @@ 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=AdminTariffCallback(action="list").pack()
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminTariffCallback(action="list").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -97,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(),
|
||||
),
|
||||
],
|
||||
[
|
||||
@@ -121,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)",
|
||||
@@ -149,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()
|
||||
)]
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -9,11 +11,13 @@ from aiogram.types import (
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
)
|
||||
from sqlalchemy import delete, distinct, select, update
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, distinct, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_tariff
|
||||
from database.models import Key, Server, Tariff
|
||||
from database.models import Gift, Key, Server, Tariff
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
@@ -27,6 +31,7 @@ from .keyboard import (
|
||||
build_tariff_menu_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -45,30 +50,51 @@ class TariffEditState(StatesGroup):
|
||||
editing_value = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
|
||||
)
|
||||
class TariffSubgroupState(StatesGroup):
|
||||
selecting_tariffs = State()
|
||||
entering_subgroup_title = State()
|
||||
|
||||
|
||||
class SubgroupEditState(StatesGroup):
|
||||
entering_new_title = State()
|
||||
confirming_deletion = State()
|
||||
editing_tariffs = State()
|
||||
|
||||
|
||||
MAX_TARIFF_NAME_LENGTH = 40
|
||||
MAX_SUBGROUP_TITLE_LENGTH = 40
|
||||
|
||||
|
||||
def validate_tariff_name(name: str) -> tuple[bool, str]:
|
||||
if len(name) > MAX_TARIFF_NAME_LENGTH:
|
||||
return False, f"Название тарифа слишком длинное. Максимум {MAX_TARIFF_NAME_LENGTH} символов."
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_subgroup_title(title: str) -> tuple[bool, str]:
|
||||
if len(title) > MAX_SUBGROUP_TITLE_LENGTH:
|
||||
return False, f"Название подгруппы слишком длинное. Максимум {MAX_SUBGROUP_TITLE_LENGTH} символов."
|
||||
return True, ""
|
||||
|
||||
|
||||
@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(
|
||||
@@ -85,6 +111,14 @@ async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||
@router.message(TariffCreateState.group, IsAdminFilter())
|
||||
async def process_tariff_group(message: Message, state: FSMContext):
|
||||
group_code = message.text.strip().lower()
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9_-]+", group_code):
|
||||
await message.answer(
|
||||
"❌ Код группы должен содержать только латинские буквы, цифры, дефисы и подчёркивания.\n\nПовторите ввод:",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(group_code=group_code)
|
||||
await state.set_state(TariffCreateState.name)
|
||||
await message.answer(
|
||||
@@ -97,7 +131,17 @@ async def process_tariff_group(message: Message, state: FSMContext):
|
||||
|
||||
@router.message(TariffCreateState.name, IsAdminFilter())
|
||||
async def process_tariff_name(message: Message, state: FSMContext):
|
||||
await state.update_data(name=message.text.strip())
|
||||
name = message.text.strip()
|
||||
|
||||
is_valid, error_msg = validate_tariff_name(name)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(TariffCreateState.duration)
|
||||
await message.answer(
|
||||
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
|
||||
@@ -112,9 +156,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)
|
||||
@@ -151,9 +193,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)
|
||||
@@ -165,17 +205,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()
|
||||
@@ -200,9 +236,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"),
|
||||
]
|
||||
]
|
||||
),
|
||||
@@ -212,40 +246,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 = {
|
||||
@@ -271,20 +295,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,
|
||||
}
|
||||
|
||||
|
||||
@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:
|
||||
@@ -299,12 +318,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))
|
||||
@@ -318,23 +333,63 @@ 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, session: AsyncSession):
|
||||
tariff_id = int(callback_data.action.split("|", 1)[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
group_code = tariff.group_code
|
||||
|
||||
if group_code == "gifts":
|
||||
gift_check = await session.execute(select(Gift).where(Gift.tariff_id == tariff_id).limit(1))
|
||||
if gift_check.scalar_one_or_none():
|
||||
result = await session.execute(select(Tariff).where(Tariff.group_code == "gifts", Tariff.id != tariff_id))
|
||||
other_tariffs = result.scalars().all()
|
||||
|
||||
if not other_tariffs:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нельзя удалить тариф — он используется в подарках, а других тарифов в группе 'gifts' нет.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in other_tariffs:
|
||||
builder.button(
|
||||
text=f"{t.name} — {t.price_rub}₽",
|
||||
callback_data=f"confirm_delete_tariff_with_replace|{tariff_id}|{t.id}",
|
||||
)
|
||||
builder.button(text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack())
|
||||
|
||||
await callback.message.edit_text(
|
||||
"<b>Этот тариф используется в подарках.</b>\n\n"
|
||||
"Выберите тариф, на который заменить его во всех подарках перед удалением:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена", callback_data=f"view|{tariff_id}"
|
||||
text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||
),
|
||||
]
|
||||
]
|
||||
@@ -342,6 +397,47 @@ async def confirm_tariff_deletion(
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_tariff_with_replace|"), IsAdminFilter())
|
||||
async def delete_tariff_with_gift_replacement(callback: CallbackQuery, session: AsyncSession):
|
||||
_, tariff_id_str, replacement_id_str = callback.data.split("|")
|
||||
tariff_id = int(tariff_id_str)
|
||||
replacement_id = int(replacement_id_str)
|
||||
|
||||
await session.execute(update(Gift).where(Gift.tariff_id == tariff_id).values(tariff_id=replacement_id))
|
||||
|
||||
await session.execute(update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None))
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
group_code = tariff.group_code
|
||||
|
||||
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code))
|
||||
remaining_tariffs = result.scalars().all()
|
||||
if not remaining_tariffs:
|
||||
await session.execute(update(Server).where(Server.tariff_group == group_code).values(tariff_group=None))
|
||||
|
||||
await session.commit()
|
||||
await callback.message.edit_text("🗑 Тариф удалён. Все подарки обновлены.", reply_markup=build_tariff_menu_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter())
|
||||
async def start_edit_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext):
|
||||
tariff_id = int(callback_data.action.split("|")[1])
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await state.set_state(TariffEditState.choosing_field)
|
||||
await callback.message.edit_text(
|
||||
"<b>✏️ Что вы хотите изменить?</b>",
|
||||
reply_markup=build_edit_tariff_fields_kb(tariff_id),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
|
||||
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|", 1)[1])
|
||||
@@ -355,43 +451,17 @@ 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()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter()
|
||||
)
|
||||
async def start_edit_tariff(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||
):
|
||||
tariff_id = int(callback_data.action.split("|")[1])
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await state.set_state(TariffEditState.choosing_field)
|
||||
await callback.message.edit_text(
|
||||
"<b>✏️ Что вы хотите изменить?</b>",
|
||||
reply_markup=build_edit_tariff_fields_kb(tariff_id),
|
||||
)
|
||||
await callback.message.edit_text("🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
|
||||
@@ -429,6 +499,15 @@ async def apply_edit(message: Message, state: FSMContext, session: AsyncSession)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if field == "name":
|
||||
is_valid, error_msg = validate_tariff_name(value)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
|
||||
try:
|
||||
num = int(value)
|
||||
@@ -470,9 +549,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
|
||||
):
|
||||
@@ -486,12 +563,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"
|
||||
@@ -503,4 +576,536 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
|
||||
)
|
||||
|
||||
return text, build_single_tariff_kb(tariff.id)
|
||||
return text, build_single_tariff_kb(tariff.id)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("start_subgrouping|"), IsAdminFilter())
|
||||
async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
group_code = callback.data.split("|", 1)[1]
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных тарифов для группировки.\n\nВсе тарифы уже находятся в подгруппах.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(TariffSubgroupState.selecting_tariffs)
|
||||
await state.update_data(group_code=group_code, selected_tariff_ids=[])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for tariff in tariffs:
|
||||
builder.row(InlineKeyboardButton(text=f"{tariff.name}", callback_data=f"sub_select|{tariff.id}"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"Выберите тарифы, которые нужно объединить в подгруппу:", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("sub_select|"), TariffSubgroupState.selecting_tariffs, IsAdminFilter())
|
||||
async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
data = await state.get_data()
|
||||
selected = set(data.get("selected_tariff_ids", []))
|
||||
|
||||
if tariff_id in selected:
|
||||
selected.remove(tariff_id)
|
||||
else:
|
||||
selected.add(tariff_id)
|
||||
|
||||
await state.update_data(selected_tariff_ids=list(selected))
|
||||
|
||||
group_code = data["group_code"]
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
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="➡️ Продолжить", callback_data="subgroup_continue"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping"),
|
||||
)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "subgroup_continue",
|
||||
TariffSubgroupState.selecting_tariffs,
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def ask_subgroup_title(callback: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
if not data.get("selected_tariff_ids"):
|
||||
await callback.answer("Выберите хотя бы один тариф", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(TariffSubgroupState.entering_subgroup_title)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📁 Введите название новой подгруппы:",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@router.message(TariffSubgroupState.entering_subgroup_title, IsAdminFilter())
|
||||
async def apply_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
|
||||
title = message.text.strip()
|
||||
|
||||
is_valid, error_msg = validate_subgroup_title(title)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
selected_ids = data.get("selected_tariff_ids", [])
|
||||
|
||||
if not selected_ids:
|
||||
await message.answer("❌ Нет выбранных тарифов.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id.in_(selected_ids)).values(subgroup_title=title, updated_at=datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
f"✅ {len(selected_ids)} тарифов сгруппированы в подгруппу: <b>{title}</b>.",
|
||||
reply_markup=build_tariff_menu_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_subgrouping", IsAdminFilter())
|
||||
async def cancel_subgrouping(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text("❌ Группировка в подгруппу отменена.", reply_markup=build_tariff_menu_kb())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_subgroup|"), IsAdminFilter())
|
||||
async def view_subgroup_tariffs(callback: CallbackQuery, session: AsyncSession):
|
||||
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||
|
||||
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
|
||||
if not subgroup_title:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
|
||||
return
|
||||
|
||||
tariffs_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in tariffs_dicts:
|
||||
title = f"{t['name']} — {t['price_rub']}₽"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📝 Переименовать подгруппу",
|
||||
callback_data=f"rename_subgroup|{subgroup_hash}|{group_code}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать подгруппу",
|
||||
callback_data=f"edit_subgroup_tariffs|{subgroup_hash}|{group_code}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗑 Удалить подгруппу",
|
||||
callback_data=f"delete_subgroup|{subgroup_hash}|{group_code}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📂 Подгруппа: {subgroup_title}</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("rename_subgroup|"), IsAdminFilter())
|
||||
async def start_rename_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||
|
||||
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
|
||||
if not subgroup_title:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
subgroup_title=subgroup_title,
|
||||
group_code=group_code,
|
||||
subgroup_hash=subgroup_hash,
|
||||
)
|
||||
|
||||
await state.set_state(SubgroupEditState.entering_new_title)
|
||||
await callback.message.edit_text(
|
||||
f"📝 Введите новое название подгруппы:\n<b>{subgroup_title}</b>\n\nИли нажмите Отмена.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.message(SubgroupEditState.entering_new_title, IsAdminFilter())
|
||||
async def save_new_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
|
||||
new_title = message.text.strip()
|
||||
|
||||
is_valid, error_msg = validate_subgroup_title(new_title)
|
||||
if not is_valid:
|
||||
data = await state.get_data()
|
||||
subgroup_hash = data.get("subgroup_hash")
|
||||
group_code = data.get("group_code")
|
||||
|
||||
await message.answer(
|
||||
f"❌ {error_msg}\n\nПовторите ввод:",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
old_title = data["subgroup_title"]
|
||||
group_code = data["group_code"]
|
||||
|
||||
await session.execute(
|
||||
update(Tariff)
|
||||
.where(
|
||||
Tariff.group_code == group_code,
|
||||
Tariff.subgroup_title == old_title,
|
||||
)
|
||||
.values(subgroup_title=new_title)
|
||||
)
|
||||
await session.commit()
|
||||
await state.clear()
|
||||
|
||||
create_subgroup_hash(new_title, group_code)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Подгруппа <b>{old_title}</b> переименована в <b>{new_title}</b>.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_subgroup|"), IsAdminFilter())
|
||||
async def confirm_delete_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||
|
||||
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
|
||||
if not subgroup_title:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
subgroup_title=subgroup_title,
|
||||
group_code=group_code,
|
||||
subgroup_hash=subgroup_hash,
|
||||
)
|
||||
await state.set_state(SubgroupEditState.confirming_deletion)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"❗ Вы уверены, что хотите <b>удалить</b> подгруппу <b>{subgroup_title}</b>?\n"
|
||||
"Это удалит поле `subgroup_title` у всех связанных тарифов.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Удалить", callback_data="confirm_subgroup_deletion"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "confirm_subgroup_deletion", SubgroupEditState.confirming_deletion, IsAdminFilter())
|
||||
async def perform_subgroup_deletion(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
subgroup_title = data["subgroup_title"]
|
||||
group_code = data["group_code"]
|
||||
|
||||
await session.execute(
|
||||
update(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
|
||||
.values(subgroup_title=None)
|
||||
)
|
||||
await session.commit()
|
||||
await state.clear()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подгруппа <b>{subgroup_title}</b> удалена.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_subgroup_tariffs|"), IsAdminFilter())
|
||||
async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, subgroup_hash, group_code = callback.data.split("|", 2)
|
||||
|
||||
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
|
||||
if not subgroup_title:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(
|
||||
Tariff.group_code == group_code,
|
||||
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
|
||||
)
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
all_tariffs_to_show = result.scalars().all()
|
||||
|
||||
subgroup_tariff_ids = {t.id for t in all_tariffs_to_show if t.subgroup_title == subgroup_title}
|
||||
|
||||
if not all_tariffs_to_show:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных тарифов для редактирования.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(SubgroupEditState.editing_tariffs)
|
||||
await state.update_data(
|
||||
subgroup_title=subgroup_title,
|
||||
group_code=group_code,
|
||||
subgroup_hash=subgroup_hash,
|
||||
selected_tariff_ids=list(subgroup_tariff_ids),
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for tariff in all_tariffs_to_show:
|
||||
is_in_subgroup = tariff.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="💾 Сохранить", callback_data="edit_sub_save"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование подгруппы: {subgroup_title}</b>\n\n"
|
||||
"✅ - тарифы в подгруппе\n\n"
|
||||
"Нажмите на тариф, чтобы добавить/убрать его:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_sub_toggle|"), SubgroupEditState.editing_tariffs, IsAdminFilter())
|
||||
async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
data = await state.get_data()
|
||||
selected_ids = set(data.get("selected_tariff_ids", []))
|
||||
|
||||
if tariff_id in selected_ids:
|
||||
selected_ids.remove(tariff_id)
|
||||
else:
|
||||
selected_ids.add(tariff_id)
|
||||
|
||||
await state.update_data(selected_tariff_ids=list(selected_ids))
|
||||
|
||||
subgroup_title = data["subgroup_title"]
|
||||
group_code = data["group_code"]
|
||||
subgroup_hash = data["subgroup_hash"]
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(
|
||||
Tariff.group_code == group_code,
|
||||
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
|
||||
)
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
all_tariffs_to_show = result.scalars().all()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
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="💾 Сохранить", callback_data="edit_sub_save"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
|
||||
)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data == "edit_sub_save", SubgroupEditState.editing_tariffs, IsAdminFilter())
|
||||
async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
subgroup_title = data["subgroup_title"]
|
||||
group_code = data["group_code"]
|
||||
subgroup_hash = data["subgroup_hash"]
|
||||
selected_tariff_ids = set(data.get("selected_tariff_ids", []))
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
|
||||
)
|
||||
current_subgroup_tariffs = result.scalars().all()
|
||||
current_tariff_ids = {t.id for t in current_subgroup_tariffs}
|
||||
|
||||
to_add = selected_tariff_ids - current_tariff_ids
|
||||
to_remove = current_tariff_ids - selected_tariff_ids
|
||||
|
||||
if to_remove:
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id.in_(to_remove)).values(subgroup_title=None, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
if to_add:
|
||||
await session.execute(
|
||||
update(Tariff)
|
||||
.where(Tariff.id.in_(to_add))
|
||||
.values(subgroup_title=subgroup_title, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await state.clear()
|
||||
|
||||
if not selected_tariff_ids:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подгруппа <b>{subgroup_title}</b> была расформирована.",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к группе тарифов",
|
||||
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
changes_text = []
|
||||
if to_add:
|
||||
added_names = []
|
||||
for tariff_id in to_add:
|
||||
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
|
||||
name = result.scalar_one()
|
||||
if name:
|
||||
added_names.append(name)
|
||||
changes_text.append(f"➕ Добавлено: {', '.join(added_names)}")
|
||||
|
||||
if to_remove:
|
||||
removed_names = []
|
||||
for tariff_id in to_remove:
|
||||
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
|
||||
name = result.scalar_one()
|
||||
if name:
|
||||
removed_names.append(name)
|
||||
changes_text.append(f"➖ Удалено: {', '.join(removed_names)}")
|
||||
|
||||
if not changes_text:
|
||||
changes_text.append("Изменений не было")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ <b>Подгруппа обновлена: {subgroup_title}</b>\n\n{chr(10).join(changes_text)}",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к подгруппе", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
+109
-129
@@ -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,69 +30,69 @@ 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.button(
|
||||
text="➕ Создать ключ",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_create_key", tg_id=tg_id
|
||||
).pack(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Создать подписку",
|
||||
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
for record in key_records:
|
||||
email = record.email
|
||||
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
|
||||
days = (expiry - current_time).days
|
||||
builder.button(
|
||||
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", tg_id=tg_id, data=str(email)
|
||||
).pack(),
|
||||
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(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="✉️ Сообщение",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_send_message", tg_id=tg_id
|
||||
).pack(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✉️ Сообщение",
|
||||
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(),
|
||||
),
|
||||
)
|
||||
builder.button(
|
||||
text="💸 Изменить баланс",
|
||||
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(),
|
||||
)
|
||||
)
|
||||
builder.button(
|
||||
text="🤝 Выгрузить рефералов",
|
||||
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(),
|
||||
)
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Восстановить триал",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_trial_restore", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Удалить клиента",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_delete_user", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
|
||||
).pack(),
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -100,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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -161,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))
|
||||
@@ -183,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()
|
||||
@@ -212,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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -248,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)
|
||||
@@ -261,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()
|
||||
@@ -280,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="♻️ Сбросить трафик",
|
||||
@@ -325,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()
|
||||
@@ -344,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))
|
||||
@@ -358,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()
|
||||
@@ -373,23 +326,50 @@ 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=f"edit_user_key|{tg_id}|{email}")
|
||||
builder.button(
|
||||
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_user_ban_type_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⛔ Навсегда",
|
||||
callback_data=AdminUserEditorCallback(action="users_ban_forever", tg_id=tg_id).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⏳ По сроку",
|
||||
callback_data=AdminUserEditorCallback(action="users_ban_temporary", tg_id=tg_id).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👻 Теневой бан",
|
||||
callback_data=AdminUserEditorCallback(action="users_ban_shadow", tg_id=tg_id).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=True).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -19,6 +19,7 @@ INSTRUCTIONS = "📘 Инструкции"
|
||||
TOP_FIVE = "🏆 Топ-5"
|
||||
TRIAL_SUB = "🎁 Пробная подписка"
|
||||
MY_SUB = "🔐 Моя подписка"
|
||||
RENEW_SUB = "🔄 Обновить подписку"
|
||||
|
||||
# Меню Оплат и баланса
|
||||
|
||||
@@ -28,6 +29,7 @@ COUPON = "🎟️ Активировать купон"
|
||||
COUPON_RESTART = "🎟️ Попробовать другой купон"
|
||||
YOOKASSA = "💳 ЮКасса: быстрая оплата"
|
||||
YOOMONEY = "💳 ЮМани: перевод по карте"
|
||||
FREEKASSA = "💰 FreeKassa: межд. платежи"
|
||||
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
|
||||
STARS = "⭐ Оплата Звездами"
|
||||
ROBOKASSA = "⭐ RoboKassa"
|
||||
@@ -76,7 +78,8 @@ PC_MACOS = "🍏 macOS"
|
||||
TV = "📺 Телевизор"
|
||||
ROUTER = "📶 Роутер"
|
||||
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
|
||||
RENEW_KEY = "🔄 Продлить подписку"
|
||||
RENEW_KEY = "Купить VPN 🔄 Продлить VPN"
|
||||
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
|
||||
TV_CONTINUE = "▶ Продолжить"
|
||||
TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
HWID_BUTTON = "♻️ Сбросить привязку"
|
||||
@@ -88,3 +91,11 @@ PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||
WATA_RU = "🇷🇺 WATA: Карты РФ"
|
||||
WATA_SBP = "🏦 WATA: СБП"
|
||||
WATA_INT = "🌍 WATA: Международные карты"
|
||||
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
|
||||
KASSAI_SBP = "🏦 KassaAI: СБП"
|
||||
|
||||
# Кнопки Heleket
|
||||
HELEKET_CRYPTO = "₿ Heleket: Crypto"
|
||||
|
||||
+5
-9
@@ -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]
|
||||
|
||||
+26
-44
@@ -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,23 +15,25 @@ 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,
|
||||
)
|
||||
@@ -47,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"))
|
||||
|
||||
@@ -86,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
|
||||
|
||||
@@ -126,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}")
|
||||
@@ -154,15 +147,13 @@ async def activate_coupon(
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
response_message = (
|
||||
"<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||
)
|
||||
response_message = COUPONS_DAYS_MESSAGE
|
||||
|
||||
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
|
||||
|
||||
@@ -235,7 +220,7 @@ async def handle_key_extension(
|
||||
if key.tariff_id:
|
||||
tariff = await get_tariff_by_id(session, key.tariff_id)
|
||||
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
|
||||
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else None
|
||||
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else 0
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=key.server_id,
|
||||
@@ -243,23 +228,22 @@ async def handle_key_extension(
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
hwid_device_limit=device_limit,
|
||||
session=session
|
||||
reset_traffic=False,
|
||||
)
|
||||
await update_key_expiry(session, client_id, new_expiry)
|
||||
await update_coupon_usage_count(session, coupon.id)
|
||||
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(
|
||||
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon.days)}⏳ до {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}")
|
||||
@@ -287,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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from aiogram import Router
|
||||
|
||||
from .instructions import router as instructions_router
|
||||
|
||||
|
||||
router = Router(name="instructions_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -24,19 +25,19 @@ from handlers.buttons import (
|
||||
PC_PC,
|
||||
SUPPORT,
|
||||
TV_CONTINUE,
|
||||
TV_INSTRUCTIONS,
|
||||
)
|
||||
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 +80,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 +102,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 +124,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 +141,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 +170,4 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any):
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+43
-76
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -6,7 +7,14 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_key_details, mark_key_as_frozen, mark_key_as_unfrozen
|
||||
from config import TRIAL_CONFIG
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_servers,
|
||||
get_tariff_by_id,
|
||||
mark_key_as_frozen,
|
||||
mark_key_as_unfrozen,
|
||||
)
|
||||
from handlers.buttons import APPLY, BACK, CANCEL
|
||||
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
|
||||
from handlers.texts import (
|
||||
@@ -18,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
|
||||
|
||||
@@ -48,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):
|
||||
"""
|
||||
Размораживает (включает) подписку.
|
||||
"""
|
||||
@@ -67,23 +72,32 @@ 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":
|
||||
text_error = f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
logger.warning(f"Не удалось включить подписку: {result.get('error') or result.get('results')}")
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_id, [])
|
||||
|
||||
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] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения.")
|
||||
total_gb = TRIAL_CONFIG["traffic_limit_gb"]
|
||||
hwid_limit = TRIAL_CONFIG["hwid_limit"]
|
||||
else:
|
||||
total_gb = int(tariff.get("traffic_limit") or 0)
|
||||
hwid_limit = int(tariff.get("device_limit") or 0)
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
leftover = record["expiry_time"]
|
||||
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
|
||||
if leftover < 0:
|
||||
leftover = 0
|
||||
new_expiry_time = now_ms + leftover
|
||||
@@ -91,45 +105,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()
|
||||
|
||||
from database.servers import get_servers
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_id, [])
|
||||
|
||||
tariff = None
|
||||
for srv in cluster_servers:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.*
|
||||
FROM tariffs t
|
||||
JOIN servers s ON s.tariff_group = t.group_code
|
||||
WHERE s.server_name = :server_name
|
||||
ORDER BY t.duration_days DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"server_name": srv["server_name"]},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if row:
|
||||
tariff = row
|
||||
break
|
||||
|
||||
await session.commit()
|
||||
|
||||
if not tariff:
|
||||
logger.info(
|
||||
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
|
||||
)
|
||||
base_bytes = 15 * 1024
|
||||
hwid_limit = 1
|
||||
else:
|
||||
base_bytes = int(tariff.get("traffic_limit") or 0)
|
||||
hwid_limit = int(tariff.get("device_limit") or 0)
|
||||
|
||||
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||
total_gb = int((added_days / 30) * base_bytes)
|
||||
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}"
|
||||
)
|
||||
@@ -142,13 +118,12 @@ async def process_callback_unfreeze_subscription_confirm(
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
hwid_device_limit=hwid_limit,
|
||||
reset_traffic=False,
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -156,9 +131,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):
|
||||
"""
|
||||
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||
"""
|
||||
@@ -186,9 +159,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):
|
||||
"""
|
||||
Замораживает (отключает) подписку.
|
||||
"""
|
||||
@@ -205,9 +176,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)
|
||||
@@ -220,9 +189,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,
|
||||
@@ -230,11 +197,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -79,10 +82,10 @@ async def key_cluster_mode(
|
||||
is_trial = data.get("is_trial", False)
|
||||
|
||||
device_limit = 0
|
||||
traffic_limit_gb = None
|
||||
traffic_limit_gb = 0
|
||||
|
||||
if is_trial:
|
||||
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
|
||||
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
|
||||
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
|
||||
elif plan:
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
@@ -92,7 +95,22 @@ async def key_cluster_mode(
|
||||
if tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_gb = int(tariff["traffic_limit"])
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
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,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_id=least_loaded_cluster,
|
||||
tg_id=tg_id,
|
||||
@@ -106,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:
|
||||
@@ -131,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(
|
||||
@@ -147,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:
|
||||
@@ -194,23 +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", 1)
|
||||
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
|
||||
)
|
||||
else:
|
||||
tariff_duration = ""
|
||||
if tariff_info and tariff_info.get("duration_days", 0) > 0:
|
||||
duration_days = tariff_info["duration_days"]
|
||||
if duration_days >= 30:
|
||||
months = duration_days // 30
|
||||
tariff_duration = format_months(months)
|
||||
else:
|
||||
tariff_duration = format_days(duration_days)
|
||||
|
||||
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
|
||||
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
|
||||
subgroup_title=subgroup_title,
|
||||
)
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
@@ -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")
|
||||
@@ -85,14 +79,13 @@ async def key_country_mode(
|
||||
target_message = message_or_query
|
||||
safe_to_edit = True
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
if not least_loaded_cluster:
|
||||
logger.error("❌ Не удалось определить наименее загруженный кластер")
|
||||
text = "❌ Нет доступных кластеров для создания ключа."
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
except ValueError as e:
|
||||
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
|
||||
@@ -112,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):
|
||||
@@ -131,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())
|
||||
@@ -193,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"]
|
||||
@@ -218,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 = []
|
||||
@@ -244,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}")
|
||||
@@ -255,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,
|
||||
@@ -266,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:
|
||||
@@ -293,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)
|
||||
@@ -353,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()
|
||||
@@ -371,7 +337,7 @@ async def finalize_key_creation(
|
||||
|
||||
if is_trial:
|
||||
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
|
||||
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
|
||||
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
|
||||
elif data.get("tariff_id") or tariff_id:
|
||||
tariff_id = data.get("tariff_id") or tariff_id
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
@@ -387,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} не найден")
|
||||
@@ -412,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)
|
||||
@@ -528,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))
|
||||
@@ -554,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:
|
||||
@@ -566,23 +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", 1)
|
||||
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
|
||||
)
|
||||
else:
|
||||
tariff_duration = ""
|
||||
if tariff_info and tariff_info.get("duration_days", 0) > 0:
|
||||
duration_days = tariff_info["duration_days"]
|
||||
if duration_days >= 30:
|
||||
months = duration_days // 30
|
||||
tariff_duration = format_months(months)
|
||||
else:
|
||||
tariff_duration = format_days(duration_days)
|
||||
|
||||
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
|
||||
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
|
||||
subgroup_title=subgroup_title,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -608,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
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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, 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,
|
||||
@@ -24,6 +28,9 @@ from database import (
|
||||
get_tariffs_for_cluster,
|
||||
get_trial,
|
||||
)
|
||||
from database.models import Admin
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
|
||||
from handlers.admin.panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import MAIN_MENU, PAYMENT
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||
@@ -40,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")
|
||||
@@ -50,11 +58,17 @@ class Form(FSMContext):
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
@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):
|
||||
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)
|
||||
|
||||
|
||||
async def handle_key_creation(
|
||||
@@ -63,77 +77,200 @@ async def handle_key_creation(
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
current_time = datetime.now(moscow_tz)
|
||||
state_data = await state.get_data()
|
||||
if state_data.get("key_creation_in_progress"):
|
||||
logger.warning(f"[AntiSpam] Пользователь {tg_id} повторно нажал на покупку — игнор.")
|
||||
return
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
base_days = TRIAL_CONFIG["duration_days"]
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
total_days = base_days + extra_days
|
||||
expiry_time = current_time + timedelta(days=total_days)
|
||||
await state.update_data(key_creation_in_progress=True)
|
||||
|
||||
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
|
||||
try:
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
base_days = TRIAL_CONFIG["duration_days"]
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
total_days = base_days + extra_days
|
||||
expiry_time = current_time + timedelta(days=total_days)
|
||||
|
||||
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
),
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
await state.update_data(is_trial=True)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
try:
|
||||
cluster_name = await get_least_loaded_cluster(session)
|
||||
except ValueError as e:
|
||||
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
|
||||
),
|
||||
text=str(e),
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = (
|
||||
f"🚫 <b>Невозможно создать подписку</b>\n\n"
|
||||
f"📊 <b>Информация о кластере:</b>\n<blockquote>"
|
||||
f"🌐 <b>Кластер:</b> <code>{cluster_name}</code>\n"
|
||||
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
|
||||
f"💡 <b>Привяжите тариф к кластеру</b>"
|
||||
)
|
||||
else:
|
||||
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=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(is_trial=True)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
cluster_name = await get_least_loaded_cluster(session)
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
group_code = tariffs[0].get("group_code") if tariffs else None
|
||||
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
|
||||
),
|
||||
text="❌ Не удалось определить группу тарифов.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
subgroup = t.get("subgroup_title")
|
||||
grouped_tariffs[subgroup].append(t)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
for subgroup in sorted(k for k in grouped_tariffs if k):
|
||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{subgroup}",
|
||||
callback_data=f"tariff_subgroup_user|{subgroup_hash}",
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
if not tariffs:
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message
|
||||
if isinstance(message_or_query, CallbackQuery)
|
||||
else message_or_query
|
||||
),
|
||||
text="❌ Нет доступных тарифов для выбора.",
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id, cluster_name=cluster_name, group_code=group_code)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
finally:
|
||||
await state.update_data(key_creation_in_progress=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff_subgroup_user|"))
|
||||
async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
subgroup_hash = callback.data.split("|")[1]
|
||||
data = await state.get_data()
|
||||
cluster_name = data.get("cluster_name")
|
||||
group_code = data.get("group_code")
|
||||
|
||||
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
if not subgroup:
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="❌ Подгруппа не найдена.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup]
|
||||
|
||||
if not filtered:
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="❌ В этой подгруппе пока нет тарифов.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in tariffs:
|
||||
for t in filtered:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list"))
|
||||
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
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
target_message=callback.message,
|
||||
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
@router.callback_query(F.data == "back_to_tariff_group_list")
|
||||
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
await state.get_data()
|
||||
tg_id = callback.from_user.id
|
||||
await handle_key_creation(
|
||||
tg_id=tg_id,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=callback,
|
||||
)
|
||||
|
||||
|
||||
@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])
|
||||
|
||||
@@ -164,7 +301,7 @@ async def select_tariff_plan(
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
await handle_custom_amount_input(message=callback_query, session=session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||
@@ -181,9 +318,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,
|
||||
@@ -205,11 +340,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()
|
||||
|
||||
|
||||
|
||||
+150
-101
@@ -1,17 +1,21 @@
|
||||
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,
|
||||
@@ -20,10 +24,10 @@ from database import (
|
||||
get_tariffs,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
check_tariff_exists,
|
||||
)
|
||||
from database.models import Server, Key
|
||||
from handlers.buttons import BACK, MAIN_MENU, PAYMENT
|
||||
from database.models import Key, Server
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
|
||||
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
|
||||
@@ -35,18 +39,16 @@ 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")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
@@ -61,6 +63,8 @@ async def process_callback_renew_key(
|
||||
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)
|
||||
|
||||
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
|
||||
|
||||
try:
|
||||
@@ -76,58 +80,57 @@ async def process_callback_renew_key(
|
||||
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}")
|
||||
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
|
||||
return
|
||||
|
||||
cluster_group = row[0]
|
||||
selected_tariffs = []
|
||||
target_group = cluster_group
|
||||
group_code = row[0]
|
||||
|
||||
if tariff_id:
|
||||
if await check_tariff_exists(session, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(session, tariff_id)
|
||||
|
||||
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
|
||||
target_group = current_tariff["group_code"]
|
||||
group_code = current_tariff["group_code"]
|
||||
|
||||
tariffs = await get_tariffs(session, group_code=target_group)
|
||||
|
||||
tariffs = await get_tariffs(session, group_code=group_code)
|
||||
tariffs = [t for t in tariffs if t["is_active"]]
|
||||
if not tariffs:
|
||||
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
|
||||
return
|
||||
|
||||
selected_tariffs = [t for t in tariffs if t["is_active"]]
|
||||
|
||||
if not selected_tariffs:
|
||||
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
|
||||
return
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
subgroup = t.get("subgroup_title")
|
||||
grouped_tariffs[subgroup].append(t)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in selected_tariffs:
|
||||
button_text = f"{t['name']} — {t['price_rub']}₽"
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{t['id']}|{client_id}",
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
|
||||
)
|
||||
for subgroup in sorted(k for k in grouped_tariffs if k):
|
||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=subgroup,
|
||||
callback_data=f"renew_subgroup|{subgroup_hash}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="renew_menu"))
|
||||
|
||||
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(
|
||||
@@ -137,20 +140,104 @@ async def process_callback_renew_key(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"❌ Произошла ошибка при обработке. Попробуйте позже."
|
||||
logger.error(f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_subgroup|"))
|
||||
async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
subgroup_hash = callback.data.split("|")[1]
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("renew_client_id")
|
||||
key_name = data.get("renew_key_name")
|
||||
|
||||
if not client_id or not key_name:
|
||||
await callback.message.answer("❌ Данные для подгруппы не найдены.")
|
||||
return
|
||||
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await callback.message.answer("❌ Ключ не найден.")
|
||||
return
|
||||
|
||||
server_id = record["server_id"]
|
||||
try:
|
||||
server_id_int = int(server_id)
|
||||
filter_condition = or_(
|
||||
Server.id == server_id_int,
|
||||
Server.server_name == server_id,
|
||||
Server.cluster_name == server_id,
|
||||
)
|
||||
except ValueError:
|
||||
filter_condition = or_(
|
||||
Server.server_name == server_id,
|
||||
Server.cluster_name == server_id,
|
||||
)
|
||||
|
||||
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}")
|
||||
await callback.message.answer("❌ Не удалось определить тарифную группу.")
|
||||
return
|
||||
|
||||
group_code = row[0]
|
||||
|
||||
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
|
||||
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"]]
|
||||
|
||||
if not filtered:
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="❌ В этой подгруппе пока нет тарифов.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"renew_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW_SUBGROUP] Ошибка при отображении подгруппы: {e}")
|
||||
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id, client_id = callback_query.data.split("|")[1:]
|
||||
tariff_id = callback_query.data.split("|")[1]
|
||||
tariff_id = int(tariff_id)
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("renew_client_id")
|
||||
key_name = data.get("renew_key_name")
|
||||
|
||||
if not client_id or not key_name:
|
||||
await callback_query.message.answer("❌ Данные для продления не найдены.")
|
||||
return
|
||||
|
||||
try:
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff["is_active"]:
|
||||
@@ -172,13 +259,9 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
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)
|
||||
@@ -204,7 +287,7 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
await handle_custom_amount_input(message=callback_query, session=session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||
@@ -212,21 +295,15 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
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,
|
||||
@@ -240,26 +317,16 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -276,37 +343,29 @@ 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:
|
||||
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
|
||||
return
|
||||
|
||||
if tariff["duration_days"] % 30 == 0:
|
||||
months_formatted = format_months(tariff["duration_days"] // 30)
|
||||
else:
|
||||
months_formatted = format_days(tariff["duration_days"])
|
||||
|
||||
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(
|
||||
tariff_name=months_formatted,
|
||||
tariff_name=tariff["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,
|
||||
expiry_date=formatted_expiry_date
|
||||
expiry_date=formatted_expiry_date,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
)
|
||||
|
||||
if callback_query:
|
||||
@@ -318,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:
|
||||
@@ -349,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}")
|
||||
|
||||
+151
-208
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE, TRIAL_CONFIG
|
||||
@@ -36,7 +37,7 @@ async def create_key_on_cluster(
|
||||
is_trial: bool = False,
|
||||
):
|
||||
try:
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
servers = await get_servers(session)
|
||||
cluster = servers.get(cluster_id)
|
||||
server_id_to_store = cluster_id
|
||||
|
||||
@@ -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")
|
||||
@@ -132,6 +117,7 @@ async def create_key_on_cluster(
|
||||
user_data["shortUuid"] = short_uuid
|
||||
if hwid_limit is not None:
|
||||
user_data["hwidDeviceLimit"] = hwid_limit
|
||||
logger.info(f"[Key Creation] Данные для создания клиента в Remnawave: {user_data}")
|
||||
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
@@ -140,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:
|
||||
@@ -236,7 +218,7 @@ async def create_client_on_server(
|
||||
sub_id = unique_email
|
||||
|
||||
total_gb_value = 0
|
||||
device_limit_value = None
|
||||
device_limit_value = 0
|
||||
|
||||
if is_trial:
|
||||
total_gb_value = TRIAL_CONFIG.get("traffic_limit_gb", 0)
|
||||
@@ -249,7 +231,7 @@ async def create_client_on_server(
|
||||
raise ValueError(f"Тариф с id={plan} не найден.")
|
||||
|
||||
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
|
||||
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else None
|
||||
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
@@ -286,7 +268,8 @@ async def renew_key_in_cluster(
|
||||
new_expiry_time: int,
|
||||
total_gb: int,
|
||||
session: AsyncSession,
|
||||
hwid_device_limit: int = None,
|
||||
hwid_device_limit: int = 0,
|
||||
reset_traffic: bool = True,
|
||||
):
|
||||
try:
|
||||
servers = await get_servers(session)
|
||||
@@ -301,31 +284,23 @@ 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:
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == tariff_group_row, Tariff.is_active is True)
|
||||
.where(Tariff.group_code == tariff_group_row, Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.duration_days.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -346,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,
|
||||
@@ -368,11 +339,45 @@ async def renew_key_in_cluster(
|
||||
)
|
||||
if updated:
|
||||
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
|
||||
await remna.reset_user_traffic(client_id)
|
||||
if reset_traffic:
|
||||
await remna.reset_user_traffic(client_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Не удалось продлить подписку Remnawave {client_id}"
|
||||
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}, пробуем создать")
|
||||
result = await session.execute(
|
||||
select(Key.remnawave_link, Key.key).where(Key.client_id == client_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
remnawave_link = row[0] if row else None
|
||||
row[1] if row else None
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_iso,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": remnawave_inbound_ids,
|
||||
}
|
||||
if remnawave_link and "/" in remnawave_link:
|
||||
user_data["shortUuid"] = remnawave_link.rstrip("/").split("/")[-1]
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||
if hwid_device_limit is not None:
|
||||
user_data["hwidDeviceLimit"] = hwid_device_limit
|
||||
|
||||
result = await remna.create_user(user_data)
|
||||
if result:
|
||||
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)
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
logger.error(f"Не удалось создать пользователя Remnawave {client_id}")
|
||||
else:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
|
||||
@@ -385,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:
|
||||
@@ -398,8 +401,9 @@ async def renew_key_in_cluster(
|
||||
sub_id = unique_email
|
||||
|
||||
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
|
||||
tasks.append(
|
||||
extend_client_key(
|
||||
|
||||
async def update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name):
|
||||
updated = await extend_client_key(
|
||||
xui=xui,
|
||||
inbound_id=int(inbound_id),
|
||||
email=unique_email,
|
||||
@@ -410,7 +414,24 @@ 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(
|
||||
client_id=client_id,
|
||||
email=unique_email,
|
||||
tg_id=tg_id,
|
||||
limit_ip=hwid_device_limit if hwid_device_limit is not None else 0,
|
||||
total_gb=traffic_bytes,
|
||||
expiry_time=new_expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id,
|
||||
)
|
||||
await add_client(xui, config)
|
||||
|
||||
tasks.append(update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name))
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -421,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)
|
||||
@@ -445,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()
|
||||
@@ -456,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(
|
||||
@@ -487,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
|
||||
|
||||
|
||||
@@ -531,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)
|
||||
@@ -565,11 +554,11 @@ async def update_key_on_cluster(
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active is True)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||
.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:
|
||||
@@ -584,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:
|
||||
@@ -595,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 = []
|
||||
@@ -615,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"])
|
||||
@@ -631,14 +614,14 @@ async def update_key_on_cluster(
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active is True)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||
.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
|
||||
device_limit_value = device_limit if device_limit is not None else None
|
||||
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit is not None else 0
|
||||
device_limit_value = device_limit if device_limit is not None else 0
|
||||
|
||||
config = ClientConfig(
|
||||
client_id=remnawave_client_id,
|
||||
@@ -658,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
|
||||
|
||||
|
||||
@@ -678,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:
|
||||
@@ -690,31 +667,35 @@ async def update_subscription(
|
||||
client_id = record.client_id
|
||||
old_cluster_id = record.server_id
|
||||
tariff_id = record.tariff_id
|
||||
alias = record.alias
|
||||
remnawave_link = remnawave_link or record.remnawave_link
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
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
|
||||
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else None
|
||||
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
|
||||
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))
|
||||
await session.commit()
|
||||
|
||||
new_cluster_id = (
|
||||
country_override or cluster_override or await get_least_loaded_cluster(session)
|
||||
)
|
||||
if country_override or cluster_override:
|
||||
new_cluster_id = country_override or cluster_override
|
||||
else:
|
||||
try:
|
||||
new_cluster_id = await get_least_loaded_cluster(session)
|
||||
except ValueError:
|
||||
logger.warning("[Update] Нет доступных кластеров, оставляем на старом")
|
||||
new_cluster_id = old_cluster_id
|
||||
|
||||
new_client_id, remnawave_key = await update_key_on_cluster(
|
||||
tg_id=tg_id,
|
||||
@@ -725,7 +706,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)
|
||||
@@ -755,29 +736,27 @@ async def update_subscription(
|
||||
remnawave_link=remnawave_key,
|
||||
server_id=new_cluster_id,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
)
|
||||
|
||||
|
||||
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": "У пользователя нет активных ключей."}
|
||||
|
||||
server_ids = {row.server_id for row in rows}
|
||||
server_id = list(server_ids)[0]
|
||||
|
||||
result = await session.execute(
|
||||
select(Server).where(
|
||||
select(Server)
|
||||
.where(Server.enabled.is_(True))
|
||||
.where(
|
||||
Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids)
|
||||
)
|
||||
)
|
||||
@@ -831,9 +810,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()
|
||||
@@ -877,9 +854,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)
|
||||
|
||||
@@ -892,9 +867,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 = []
|
||||
@@ -906,25 +879,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
|
||||
|
||||
@@ -942,17 +909,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 {
|
||||
@@ -961,15 +924,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)
|
||||
@@ -983,9 +942,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
|
||||
@@ -998,25 +955,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))
|
||||
@@ -1025,26 +976,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
|
||||
|
||||
+124
-127
@@ -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,23 +39,31 @@ from handlers.buttons import (
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW,
|
||||
RENEW_FULL,
|
||||
RENEW_SUB,
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.texts import FROZEN_SUBSCRIPTION_MSG, NO_SUBSCRIPTIONS_MSG, key_message
|
||||
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,
|
||||
format_hours,
|
||||
format_minutes,
|
||||
format_months,
|
||||
get_russian_month,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -63,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:
|
||||
@@ -97,7 +105,7 @@ def build_keys_response(records):
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
response_message = KEYS_HEADER
|
||||
for record in records:
|
||||
alias = record.alias
|
||||
email = record.email
|
||||
@@ -107,30 +115,21 @@ 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"
|
||||
|
||||
response_message += (
|
||||
"</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
)
|
||||
response_message += KEYS_FOOTER
|
||||
else:
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
@@ -144,36 +143,28 @@ 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,
|
||||
text="✏️ Введите новое имя подписки (до 10 символов):",
|
||||
text=RENAME_KEY_PROMPT,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
@@ -187,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()
|
||||
|
||||
@@ -209,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
|
||||
|
||||
@@ -230,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(
|
||||
@@ -251,38 +234,41 @@ async def render_key_info(
|
||||
time_left = expiry_date - datetime.utcnow()
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
|
||||
days_left_message = DAYS_LEFT_MESSAGE
|
||||
else:
|
||||
total_seconds = int(time_left.total_seconds())
|
||||
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)
|
||||
if is_full_remnawave and client_id:
|
||||
servers = await get_servers(session)
|
||||
remna_server = next(
|
||||
(
|
||||
srv
|
||||
for cl in servers.values()
|
||||
for srv in cl
|
||||
if srv.get("panel_type") == "remnawave"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if remna_server:
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
hwid_count = len(devices or [])
|
||||
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"),
|
||||
None,
|
||||
)
|
||||
if remna_server:
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
hwid_count = len(devices or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении HWID для {client_id}: {e}")
|
||||
|
||||
tariff_name = ""
|
||||
traffic_limit = 0
|
||||
device_limit = 0
|
||||
subgroup_title = ""
|
||||
tariff = None
|
||||
if record.get("tariff_id"):
|
||||
tariff = await get_tariff_by_id(session, record["tariff_id"])
|
||||
@@ -290,15 +276,9 @@ async def render_key_info(
|
||||
tariff_name = tariff["name"]
|
||||
traffic_limit = tariff.get("traffic_limit", 0)
|
||||
device_limit = tariff.get("device_limit", 0)
|
||||
subgroup_title = tariff.get("subgroup_title", "")
|
||||
|
||||
tariff_duration = ""
|
||||
if tariff and tariff.get("duration_days", 0) > 0:
|
||||
duration_days = tariff["duration_days"]
|
||||
if duration_days >= 30:
|
||||
months = duration_days // 30
|
||||
tariff_duration = format_months(months)
|
||||
else:
|
||||
tariff_duration = format_days(duration_days)
|
||||
tariff_duration = tariff_name
|
||||
|
||||
response_message = key_message(
|
||||
final_link,
|
||||
@@ -309,47 +289,30 @@ async def render_key_info(
|
||||
hwid_count=hwid_count if device_limit is not None else 0,
|
||||
tariff_name=tariff_duration,
|
||||
traffic_limit=traffic_limit,
|
||||
device_limit=device_limit
|
||||
device_limit=device_limit,
|
||||
subgroup_title=subgroup_title,
|
||||
)
|
||||
|
||||
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
text=RENEW_SUB,
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -364,27 +327,14 @@ async def render_key_info(
|
||||
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}")
|
||||
)
|
||||
|
||||
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"))
|
||||
@@ -410,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:
|
||||
@@ -431,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)
|
||||
@@ -444,9 +385,65 @@ 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)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "renew_menu")
|
||||
@router.callback_query(F.data == "extend")
|
||||
@router.message(F.text == "/extend")
|
||||
async def process_renew_menu(callback_query_or_message: CallbackQuery | Message, session: Any):
|
||||
try:
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
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()
|
||||
for servers in servers_dict.values():
|
||||
for s in servers:
|
||||
all_server_names.add(s["server_name"])
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
if records:
|
||||
for record in records:
|
||||
if getattr(record, "is_frozen", False):
|
||||
continue
|
||||
alias = record.alias
|
||||
email = record.email
|
||||
expiry_time = record.expiry_time
|
||||
server_id = record.server_id
|
||||
key_display = alias.strip() if alias else email
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
now = datetime.now(moscow_tz)
|
||||
days_left = (expiry_date_full - now).days
|
||||
if (expiry_date_full - now).total_seconds() <= 0:
|
||||
days_text = "🔴 Истекла"
|
||||
else:
|
||||
days_text = format_days(days_left)
|
||||
else:
|
||||
days_text = "истекла"
|
||||
server_info = f" ({server_id})" if server_id in all_server_names else ""
|
||||
btn_text = f"🔑 {key_display} (⏳{days_text}) {server_info}"
|
||||
builder.row(InlineKeyboardButton(text=btn_text, callback_data=f"renew_key|{email}"))
|
||||
text = SELECT_SUBS
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении подписок для продления: {e}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
+9
-26
@@ -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,
|
||||
|
||||
@@ -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,18 +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 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:
|
||||
@@ -35,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:
|
||||
@@ -49,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]
|
||||
@@ -61,14 +57,16 @@ async def combine_unique_lines(
|
||||
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
|
||||
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
all_lines = set()
|
||||
all_lines = []
|
||||
all_headers = []
|
||||
for result in results:
|
||||
if isinstance(result, tuple):
|
||||
lines, headers = result
|
||||
all_lines.update(filter(None, lines))
|
||||
for line in filter(None, lines):
|
||||
if line not in all_lines:
|
||||
all_lines.append(line)
|
||||
all_headers.append(headers)
|
||||
return list(all_lines), all_headers
|
||||
return all_lines, all_headers
|
||||
|
||||
|
||||
async def get_subscription_urls(
|
||||
@@ -77,7 +75,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)
|
||||
select(Server.subscription_url).where(Server.server_name == server_id, Server.enabled.is_(True))
|
||||
)
|
||||
server_data = result.scalar()
|
||||
if server_data:
|
||||
@@ -92,6 +90,9 @@ async def get_subscription_urls(
|
||||
if include_remnawave_key:
|
||||
urls.append(include_remnawave_key)
|
||||
|
||||
if RANDOM_SUBSCRIPTIONS:
|
||||
random.shuffle(urls)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
@@ -100,9 +101,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
|
||||
@@ -143,9 +142,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)
|
||||
@@ -195,41 +192,36 @@ def prepare_headers(
|
||||
) -> dict[str, str]:
|
||||
if "Happ" in user_agent:
|
||||
encoded_project_name = f"{project_name}"
|
||||
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
|
||||
announce_str = HAPP_ANNOUNCE.format(subscription_info=subscription_info)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
elif "Hiddify" in user_agent:
|
||||
parts = subscription_info.split(" - ")[0].split(": ")
|
||||
key_info = parts[1] if len(parts) > 1 else parts[0]
|
||||
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
|
||||
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:
|
||||
encoded_project_name = f"{project_name}\n{subscription_info}"
|
||||
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
|
||||
announce_str = V2RAYTUN_ANNOUNCE
|
||||
return {
|
||||
"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:
|
||||
@@ -238,8 +230,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -256,14 +247,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"]
|
||||
@@ -271,35 +258,21 @@ 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
|
||||
)
|
||||
if RANDOM_SUBSCRIPTIONS:
|
||||
random.shuffle(combined_subscriptions)
|
||||
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
|
||||
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")
|
||||
subscription_info = f"📄 Подписка: {email} — {time_left}"
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -403,7 +349,8 @@ async def handle_expired_keys(
|
||||
get_renewal_message(
|
||||
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
|
||||
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
),
|
||||
)
|
||||
continue
|
||||
@@ -416,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}"
|
||||
@@ -428,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:
|
||||
@@ -463,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)
|
||||
@@ -489,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']}."
|
||||
@@ -517,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 часов, повторное продление отменено."
|
||||
@@ -532,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
|
||||
@@ -572,21 +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 = ""
|
||||
if duration_days > 0:
|
||||
if duration_days >= 30:
|
||||
months = duration_days // 30
|
||||
tariff_duration = format_months(months)
|
||||
else:
|
||||
tariff_duration = format_days(duration_days)
|
||||
selected_tariff["name"]
|
||||
renewal_cost = selected_tariff["price_rub"]
|
||||
traffic_limit = selected_tariff["traffic_limit"]
|
||||
device_limit = selected_tariff["device_limit"]
|
||||
@@ -598,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(
|
||||
@@ -618,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))
|
||||
@@ -627,25 +543,21 @@ async def process_auto_renew_or_notify(
|
||||
await delete_notification(conn, tg_id, notification_id)
|
||||
|
||||
renewed_message = get_renewal_message(
|
||||
tariff_name=tariff_duration,
|
||||
traffic_limit=selected_tariff.get("traffic_limit") if selected_tariff.get("traffic_limit") is not None else 0,
|
||||
tariff_name=selected_tariff["name"],
|
||||
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
|
||||
expiry_date=formatted_expiry_date,
|
||||
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}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY
|
||||
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION
|
||||
|
||||
|
||||
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
@@ -11,7 +11,7 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=RENEW_KEY, callback_data=f"renew_key|{email}")
|
||||
builder.button(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{email}")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
NOTIFY_INACTIVE,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
@@ -18,17 +21,18 @@ from database import (
|
||||
mark_trial_extended,
|
||||
update_key_notified,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from database.models import Key
|
||||
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 (
|
||||
TRIAL_INACTIVE_BONUS_MSG,
|
||||
TRIAL_INACTIVE_FIRST_MSG,
|
||||
ZERO_TRAFFIC_MSG,
|
||||
)
|
||||
from handlers.utils import format_days
|
||||
from handlers.utils import format_days, is_full_remnawave_cluster
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_messages_with_limit
|
||||
|
||||
router = Router()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
@@ -50,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()
|
||||
|
||||
@@ -74,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(
|
||||
@@ -100,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 = []
|
||||
@@ -118,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:
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -140,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:
|
||||
@@ -157,28 +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()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
|
||||
)
|
||||
|
||||
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)))
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{email}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при определении типа панели для {email}: {e}")
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}"))
|
||||
|
||||
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(
|
||||
|
||||
+74
-11
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -11,8 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
DONATIONS_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
HELEKET_ENABLE,
|
||||
KASSAI_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
WATA_INT_ENABLE,
|
||||
WATA_RU_ENABLE,
|
||||
WATA_SBP_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
@@ -22,42 +29,59 @@ from handlers.buttons import (
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
CRYPTOBOT,
|
||||
FREEKASSA,
|
||||
HELEKET_CRYPTO,
|
||||
KASSAI_CARDS,
|
||||
KASSAI_SBP,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
ROBOKASSA,
|
||||
STARS,
|
||||
WATA_INT,
|
||||
WATA_RU,
|
||||
WATA_SBP,
|
||||
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.heleket import process_callback_pay_heleket
|
||||
from handlers.payments.kassai import process_callback_pay_kassai
|
||||
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.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:
|
||||
payment_handlers.append(process_callback_pay_yookassa)
|
||||
if YOOMONEY_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_yoomoney)
|
||||
if KASSAI_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_kassai)
|
||||
if HELEKET_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_heleket)
|
||||
if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_wata)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_cryptobot)
|
||||
if STARS_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_stars)
|
||||
if ROBOKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_robokassa)
|
||||
if FREEKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_freekassa)
|
||||
|
||||
if len(payment_handlers) == 1:
|
||||
await callback_query.answer()
|
||||
@@ -69,16 +93,27 @@ async def handle_pay(
|
||||
builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa"))
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney"))
|
||||
if KASSAI_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=KASSAI_CARDS, callback_data="pay_kassai_cards"))
|
||||
builder.row(InlineKeyboardButton(text=KASSAI_SBP, callback_data="pay_kassai_sbp"))
|
||||
if HELEKET_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=HELEKET_CRYPTO, callback_data="pay_heleket_crypto"))
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
|
||||
if STARS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=STARS, callback_data="pay_stars"))
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
|
||||
if FREEKASSA_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa"))
|
||||
if WATA_RU_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru"))
|
||||
if WATA_SBP_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=WATA_SBP, callback_data="pay_wata_sbp"))
|
||||
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"))
|
||||
|
||||
@@ -98,14 +133,12 @@ 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"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
image_path = os.path.join("img", "pay.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -143,3 +176,33 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
media_path=None,
|
||||
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")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_cards")
|
||||
async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="cards")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_sbp")
|
||||
async def handle_pay_kassai_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_heleket_crypto")
|
||||
async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
|
||||
|
||||
@@ -4,18 +4,25 @@ from aiogram import Router
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
KASSAI_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
HELEKET_ENABLE,
|
||||
)
|
||||
|
||||
from .cryprobot_pay import router as cryprobot_router
|
||||
from .freekassa_pay import router as freekassa_router
|
||||
from .gift import router as gift_router
|
||||
from .kassai import router as kassai_router
|
||||
from .robokassa_pay import router as robokassa_router
|
||||
from .stars_pay import router as stars_router
|
||||
from .yookassa_pay import router as yookassa_router
|
||||
from .yoomoney_pay import router as yoomoney_router
|
||||
from .wata import router as wata_router
|
||||
from .heleket import router as heleket_router
|
||||
|
||||
router = Router(name="payments_main_router")
|
||||
|
||||
@@ -25,9 +32,16 @@ if YOOMONEY_ENABLE:
|
||||
router.include_router(yoomoney_router)
|
||||
if ROBOKASSA_ENABLE:
|
||||
router.include_router(robokassa_router)
|
||||
if FREEKASSA_ENABLE:
|
||||
router.include_router(freekassa_router)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
router.include_router(cryprobot_router)
|
||||
if STARS_ENABLE:
|
||||
router.include_router(stars_router)
|
||||
if KASSAI_ENABLE:
|
||||
router.include_router(kassai_router)
|
||||
if HELEKET_ENABLE:
|
||||
router.include_router(heleket_router)
|
||||
|
||||
router.include_router(wata_router)
|
||||
router.include_router(gift_router)
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user