Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fc136d1e | |||
| eea908da36 | |||
| 04ff996d14 | |||
| c3dd90a9a9 | |||
| 3c8b1fe640 | |||
| 0f449680ee | |||
| 4fa5c882ba | |||
| 8f031d3d1f | |||
| 534313ce12 | |||
| 586ff2e895 | |||
| 3f62af27b7 | |||
| bf01c6bcfc | |||
| ec8adb0907 | |||
| eafe0c56af | |||
| b09a71aeaf | |||
| dd6ce9ff1d | |||
| 90291798eb | |||
| 1d42ab1f04 | |||
| 74b991252a | |||
| f6b3c31d5a | |||
| 6324426b27 | |||
| b58dc6ab82 | |||
| fc130e86f9 | |||
| bb63732e91 | |||
| a96717bf2a | |||
| a198d1bce0 | |||
| 2041e66956 | |||
| 2f1d8ff9e5 | |||
| 2a68f8e36c | |||
| 472a099a8f | |||
| 3ca8c0f960 | |||
| 13fd27aa96 | |||
| cc57c8610b | |||
| 3f8db29405 | |||
| bb8b07c318 | |||
| 0aa4cf0996 | |||
| 355f210b8c | |||
| b290fc6382 | |||
| 0cc39b4d7f | |||
| 3c00f0cc10 | |||
| b9c0701fe6 | |||
| 0fd8948870 | |||
| 833721bd60 | |||
| 8693115571 | |||
| 7f6f00286c | |||
| 5556bfc6c7 | |||
| 14b97a9c3d | |||
| 4372e651b9 | |||
| 1f47f723d0 | |||
| e4b79d4410 | |||
| 0873cdb16d | |||
| 0265e9cb50 | |||
| 3e1f78f94c | |||
| 81d44d9f7c | |||
| 62cde5d6d5 | |||
| 22d82c9199 | |||
| 84e9637453 | |||
| db16c0d49c | |||
| a967352e9a | |||
| 93acfa3af9 | |||
| 8dca92b5d9 | |||
| 08f0ab2846 | |||
| 6477afd00f | |||
| 6478495e6e | |||
| a60405e4f4 | |||
| f2a9f81ddf | |||
| 80bf976fe5 | |||
| 1b2de84f2d | |||
| 858968665b | |||
| af2d6507da | |||
| 786e2d2288 | |||
| 12c1d5badb | |||
| 2eeccf99ee | |||
| 3eb3fd229e | |||
| d88d1d9482 | |||
| abd0315244 | |||
| 80dc53f272 | |||
| 218a5b544d | |||
| 2e25957855 | |||
| f60a5b7506 | |||
| 316b6359fc | |||
| a6ed138c09 | |||
| 9c8b2c8c50 | |||
| 457048579a | |||
| 78afc78ab7 | |||
| e187d1167b | |||
| 35e9a6d104 | |||
| f32ebc95cd | |||
| 79ddde90ca | |||
| adfadcf127 | |||
| 8a0b9b7c98 | |||
| 9bec6fe341 | |||
| ab39653bab | |||
| a7e3f302d6 | |||
| 38a8fe251a | |||
| 03763a79f6 | |||
| bdbd262fce | |||
| ea87cc0393 | |||
| 5f40605e9f | |||
| 86836da169 | |||
| 92e2e8cb3f | |||
| f6ca1bda22 | |||
| 2025329e5f | |||
| e71a20cd3e | |||
| 20c9b36c26 | |||
| 5c2bde726c | |||
| 7937bceb9c | |||
| 5aa762aaf1 | |||
| 3d2269a368 | |||
| 4f5878bc4e | |||
| 851e452292 | |||
| 985ae18bfb | |||
| 9c999e7220 | |||
| 877c005a6f | |||
| 1b1780df55 | |||
| 9bacc0c8e3 | |||
| 01dea3cccf | |||
| 748adc6697 | |||
| 919335d858 | |||
| ab4418b503 | |||
| 654b20c18f | |||
| 8183c2b678 | |||
| ccacc98b0e | |||
| 0d11a9b645 | |||
| 6a29a7acd4 | |||
| ea91d1f7b0 | |||
| ef59eeac4c | |||
| 26da97187c | |||
| 48ebdeca0f | |||
| 2b1018d7d3 | |||
| 317d905a2a | |||
| 610ccdd1f9 | |||
| d2288c62a3 | |||
| 9f10fed3e7 | |||
| 1140a6c050 | |||
| f2d14d69b0 | |||
| 28cfe91175 | |||
| 7a985eb9c6 | |||
| afd99691f6 | |||
| 153be452b0 | |||
| 679e65ec44 | |||
| f44bd9e4b6 | |||
| 0385e15815 | |||
| b109834543 | |||
| 9f353e8b5c | |||
| 8b77ccda9e | |||
| 1d5c80cd35 | |||
| 041e1c898d | |||
| 76390df4bc | |||
| c3941397cf | |||
| 05e8bfece4 | |||
| f6f9fdce18 | |||
| 4525769370 | |||
| 60447b41f6 | |||
| 045fbddecb | |||
| 84d8024d08 | |||
| 617cdbad10 | |||
| 532f066ffb | |||
| e29e903efa | |||
| e1602da083 | |||
| 96b977adbd | |||
| e16f5a50ee | |||
| 478a6ecd8b | |||
| 6a3def212c |
+3
-3
@@ -43,7 +43,6 @@ database.db
|
||||
bot_old.py
|
||||
bot_old_2.py
|
||||
backup_pg.sh
|
||||
docker-compose.yml
|
||||
config copy.py
|
||||
handlers/texts.py
|
||||
|
||||
@@ -53,11 +52,12 @@ Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
Dockerfile
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
storage/
|
||||
|
||||
.license_state
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /app/venv \
|
||||
&& python -m venv /app/venv \
|
||||
&& /app/venv/bin/pip install --upgrade pip \
|
||||
&& /app/venv/bin/pip install -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
+46
-7
@@ -1,15 +1,50 @@
|
||||
from fastapi import FastAPI
|
||||
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from api.routes import (
|
||||
users,
|
||||
keys,
|
||||
coupons,
|
||||
servers,
|
||||
tariffs,
|
||||
gifts,
|
||||
referrals,
|
||||
misc,
|
||||
partners,
|
||||
modules,
|
||||
management,
|
||||
settings,
|
||||
)
|
||||
from config import API_LOGGING
|
||||
from logger import logger
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (preAlpha)",
|
||||
version="0.2.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json"
|
||||
title="SoloBot API (Alpha)",
|
||||
version="0.5.2",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
if not API_LOGGING:
|
||||
return await call_next(request)
|
||||
|
||||
started = perf_counter()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
client_ip = request.client.host if request.client else "-"
|
||||
path_qs = request.url.path
|
||||
if request.url.query:
|
||||
path_qs = f"{path_qs}?{request.url.query}"
|
||||
|
||||
logger.info(
|
||||
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
|
||||
)
|
||||
return response
|
||||
|
||||
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"])
|
||||
@@ -17,7 +52,11 @@ 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(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
app.include_router(modules.router, prefix="/api")
|
||||
app.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
|
||||
|
||||
@app.get("/api", include_in_schema=False)
|
||||
|
||||
+35
-16
@@ -1,4 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
@@ -7,13 +9,23 @@ from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
@@ -35,7 +47,10 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
return result.scalars().all()
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@@ -49,7 +64,7 @@ def generate_crud_router(
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@@ -60,12 +75,12 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
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
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@@ -76,52 +91,56 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
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
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: schema_create, # type: ignore
|
||||
payload: Any = Body(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.dict(exclude_unset=True)
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(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
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: schema_update, # type: ignore
|
||||
payload: Any = Body(...),
|
||||
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)
|
||||
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():
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@@ -132,7 +151,7 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from config import API_TOKEN
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.settings.management_config import update_management_config
|
||||
from database.models import Key, User
|
||||
from database.models import Server
|
||||
from handlers.admin.sender.sender_service import BroadcastService
|
||||
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
|
||||
if is_systemd:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
admin=Depends(verify_admin_token),
|
||||
):
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
|
||||
new_domain_url = f"https://{domain}"
|
||||
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(admin=Depends(verify_admin_token)):
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
text_raw = (payload.text or "").strip()
|
||||
if not text_raw:
|
||||
raise HTTPException(status_code=400, detail="Broadcast text is required")
|
||||
|
||||
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
|
||||
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
|
||||
|
||||
clean_text, keyboard = parse_message_buttons(text_raw)
|
||||
|
||||
max_len = 1024 if payload.photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
|
||||
|
||||
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
|
||||
if not tg_ids:
|
||||
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
|
||||
|
||||
bot = _get_broadcast_bot()
|
||||
messages = [
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": clean_text,
|
||||
"photo": payload.photo,
|
||||
"keyboard": keyboard,
|
||||
}
|
||||
for tg_id in tg_ids
|
||||
]
|
||||
|
||||
workers = max(1, min(int(payload.workers or 5), 30))
|
||||
rate = max(1, min(int(payload.messages_per_second or 35), 60))
|
||||
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
|
||||
stats = await broadcast_service.broadcast(messages, workers=workers)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Broadcast completed",
|
||||
"recipients": total_users,
|
||||
"stats": stats,
|
||||
}
|
||||
@@ -9,7 +9,6 @@ from api.schemas import (
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
ReferralResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
@@ -20,7 +19,6 @@ from database.models import (
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
TemporaryData,
|
||||
TrackingSource,
|
||||
)
|
||||
@@ -56,20 +54,6 @@ async def get_payments_by_tg_id(
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_admin_token
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
changed = False
|
||||
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(admin=Depends(verify_admin_token)):
|
||||
refresh = getattr(manager, "refresh_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
else:
|
||||
legacy_refresh = getattr(manager, "_load_state", None)
|
||||
if callable(legacy_refresh):
|
||||
legacy_refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
local_version = _read_local_module_version(name)
|
||||
item["local_version"] = local_version
|
||||
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"item": _module_state(name)}
|
||||
@@ -0,0 +1,962 @@
|
||||
from datetime import datetime
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
|
||||
except Exception:
|
||||
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_percent(value: float) -> float | None:
|
||||
"""Normalize percent input to 0-100 range."""
|
||||
try:
|
||||
val = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if 0.0 <= val <= 1.0:
|
||||
val *= 100.0
|
||||
|
||||
if 0.0 <= val <= 100.0:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _default_partner_percent() -> float:
|
||||
try:
|
||||
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _row_dt_iso(value) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список всех партнёров со статистикой.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total": int,
|
||||
"items": [
|
||||
{
|
||||
"tg_id": int,
|
||||
"balance": float,
|
||||
"percent": float,
|
||||
"code": str | None,
|
||||
"method": str | None,
|
||||
"referred_count": int
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
partners_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
p.partner_tg_id AS tg_id,
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method,
|
||||
COUNT(p.joined_tg_id) as joined_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
|
||||
ORDER BY partner_balance DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
count_sql = text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
|
||||
partners = result.fetchall()
|
||||
|
||||
count_result = await session.execute(count_sql)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
partners_list = []
|
||||
default_percent = _default_partner_percent()
|
||||
for partner in partners:
|
||||
percent_value = partner[2]
|
||||
percent_custom = bool(partner[3])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
else:
|
||||
percent = float(default_percent)
|
||||
|
||||
partners_list.append({
|
||||
"tg_id": int(partner[0]),
|
||||
"balance": float(partner[1] or 0),
|
||||
"percent": percent,
|
||||
"code": partner[4] or None,
|
||||
"method": partner[5] or None,
|
||||
"referred_count": int(partner[6] or 0),
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": total, "items": partners_list})
|
||||
|
||||
|
||||
@router.get("/stats/all")
|
||||
async def get_partners_stats(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает общую статистику партнёрской программы.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total_partners": int,
|
||||
"partners_today": int,
|
||||
"total_referred": int,
|
||||
"total_balance": float,
|
||||
"top_partner_tg_id": int,
|
||||
"top_partner_refs": int
|
||||
}
|
||||
"""
|
||||
|
||||
stats_sql = text(
|
||||
"""
|
||||
WITH partner_refs AS (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
|
||||
(
|
||||
SELECT COUNT(DISTINCT partner_tg_id)
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
AND DATE(created_at) = CURRENT_DATE
|
||||
) AS partners_today,
|
||||
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
|
||||
(
|
||||
SELECT COALESCE(SUM(u.partner_balance), 0.0)
|
||||
FROM users u
|
||||
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
|
||||
) AS total_balance,
|
||||
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
|
||||
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
|
||||
"""
|
||||
)
|
||||
|
||||
stats_result = await session.execute(stats_sql)
|
||||
stats_row = stats_result.fetchone()
|
||||
|
||||
if stats_row:
|
||||
stats = {
|
||||
"total_partners": int(stats_row[0] or 0),
|
||||
"partners_today": int(stats_row[1] or 0),
|
||||
"total_referred": int(stats_row[2] or 0),
|
||||
"total_balance": float(stats_row[3] or 0.0),
|
||||
"top_partner_tg_id": int(stats_row[4] or 0),
|
||||
"top_partner_refs": int(stats_row[5] or 0),
|
||||
}
|
||||
else:
|
||||
stats = {
|
||||
"total_partners": 0,
|
||||
"partners_today": 0,
|
||||
"total_referred": 0,
|
||||
"total_balance": 0.0,
|
||||
"top_partner_tg_id": 0,
|
||||
"top_partner_refs": 0,
|
||||
}
|
||||
|
||||
return JSONResponse(content=stats)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}")
|
||||
async def update_partner(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
balance: float = Query(..., description="Новый баланс партнёра"),
|
||||
percent: float = Query(..., description="Новый процент партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет данные партнёра (баланс и процент).
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
stmt = text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_balance = :balance, partner_percent = :percent
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
|
||||
status_code=200,
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tg_id}")
|
||||
async def get_partner_data(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает партнёрские данные для указанного `tg_id`.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"tg_id": int,
|
||||
"partner_balance": float,
|
||||
"partner_percent": float,
|
||||
"partner_code": str | None,
|
||||
"payout_method": str | None,
|
||||
"invited": [
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
meta_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method
|
||||
FROM users u
|
||||
WHERE u.tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
|
||||
meta_row = meta_res.fetchone()
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
default_percent = _default_partner_percent()
|
||||
percent = default_percent
|
||||
if meta_row:
|
||||
percent_value = meta_row[1]
|
||||
percent_custom = bool(meta_row[2])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
|
||||
response = {
|
||||
"tg_id": tg_id,
|
||||
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
|
||||
"partner_percent": percent,
|
||||
"partner_code": meta_row[3] if meta_row else None,
|
||||
"payout_method": meta_row[4] if meta_row else None,
|
||||
"invited": [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
],
|
||||
}
|
||||
|
||||
return JSONResponse(content=response)
|
||||
|
||||
|
||||
@router.post("/{tg_id}/invited")
|
||||
async def add_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Добавляет приглашённого пользователю партнёра."""
|
||||
|
||||
if joined_tg_id == tg_id:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
partner_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
if not partner_exists.scalar():
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
invited_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
if not invited_exists.scalar():
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Приглашённый пользователь не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
existing = await session.execute(
|
||||
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
existing_partner = existing.scalar()
|
||||
if existing_partner is not None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
||||
VALUES (:partner_tg_id, :joined_tg_id)
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый добавлен",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}/invited/{joined_tg_id}")
|
||||
async def delete_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет приглашённого у партнёра."""
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM partners
|
||||
WHERE partner_tg_id = :partner_tg_id
|
||||
AND joined_tg_id = :joined_tg_id
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый удалён",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent")
|
||||
async def update_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет персональный процент партнёра."""
|
||||
|
||||
normalized = _parse_percent(percent)
|
||||
if normalized is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = :percent, partner_percent_custom = true
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id, "percent": normalized},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Процент обновлён", "percent": normalized},
|
||||
status_code=200,
|
||||
)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/balance")
|
||||
async def update_partner_balance(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
amount: float = Query(..., description="Сумма операции"),
|
||||
mode: str = Query("set", description="Режим: set, add, subtract"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Изменяет баланс партнёрской программы."""
|
||||
|
||||
mode_normalized = (mode or "set").strip().lower()
|
||||
if mode_normalized not in {"set", "add", "subtract"}:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
amount_val = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверная сумма"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if amount_val < 0:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Сумма не может быть отрицательной"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
current_res = await session.execute(
|
||||
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
current_balance = current_res.scalar()
|
||||
if current_balance is None:
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
current_balance = float(current_balance or 0.0)
|
||||
|
||||
if mode_normalized == "set":
|
||||
new_balance = amount_val
|
||||
elif mode_normalized == "add":
|
||||
new_balance = current_balance + amount_val
|
||||
else:
|
||||
if current_balance < amount_val:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Недостаточно средств"},
|
||||
status_code=400,
|
||||
)
|
||||
new_balance = current_balance - amount_val
|
||||
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id, "balance": new_balance},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/invited")
|
||||
async def get_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список приглашённых пользователей конкретного партнёра.
|
||||
|
||||
Структура ответа:
|
||||
[
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
"""
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
invited_list = [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
]
|
||||
|
||||
return JSONResponse(content=invited_list)
|
||||
|
||||
|
||||
@router.get("/payouts/pending")
|
||||
async def get_partner_payouts_pending(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список ожидающих заявок на вывод."""
|
||||
|
||||
where_clause = "WHERE pr.status = 'pending'"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at ASC, pr.id ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "pending",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.get("/payouts/history")
|
||||
async def get_partner_payouts_history(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает историю выплат (approved/rejected)."""
|
||||
|
||||
where_clause = "WHERE pr.status IN ('approved','rejected')"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at DESC, pr.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "—",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/approve")
|
||||
async def approve_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Одобряет заявку на вывод."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'approved', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/reject")
|
||||
async def reject_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'rejected', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
|
||||
if user is not None:
|
||||
current_balance = float(user[2] or 0.0)
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent/reset")
|
||||
async def reset_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает персональный процент партнёра к дефолту."""
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = NULL, partner_percent_custom = false
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/code")
|
||||
async def update_partner_code(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет код партнёрской ссылки."""
|
||||
|
||||
raw = (code or "").strip().lower()
|
||||
if not raw:
|
||||
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
|
||||
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
if exists.first():
|
||||
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
|
||||
|
||||
result = await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/reset-disabled-methods")
|
||||
async def reset_disabled_payout_methods(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает реквизиты для отключённых способов вывода."""
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import (
|
||||
ENABLE_PAYOUT_CARD,
|
||||
ENABLE_PAYOUT_SBP,
|
||||
ENABLE_PAYOUT_TON,
|
||||
ENABLE_PAYOUT_USDT,
|
||||
)
|
||||
from modules.partner_program import buttons as B
|
||||
except Exception:
|
||||
ENABLE_PAYOUT_CARD = True
|
||||
ENABLE_PAYOUT_USDT = True
|
||||
ENABLE_PAYOUT_TON = True
|
||||
ENABLE_PAYOUT_SBP = True
|
||||
B = None
|
||||
|
||||
disabled = []
|
||||
if not ENABLE_PAYOUT_CARD and B:
|
||||
disabled.append(B.METHOD_CARD)
|
||||
if not ENABLE_PAYOUT_USDT and B:
|
||||
disabled.append(B.METHOD_USDT)
|
||||
if not ENABLE_PAYOUT_TON and B:
|
||||
disabled.append(B.METHOD_TON)
|
||||
if not ENABLE_PAYOUT_SBP and B:
|
||||
disabled.append(B.METHOD_SBP)
|
||||
|
||||
if not disabled:
|
||||
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET card_number = NULL
|
||||
WHERE payout_method = ANY(:methods)
|
||||
"""
|
||||
),
|
||||
{"methods": disabled},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/export")
|
||||
async def export_partner_invites_csv(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Экспортирует приглашённых партнёром в CSV."""
|
||||
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT joined_tg_id, created_at
|
||||
FROM partners
|
||||
WHERE partner_tg_id = :tg_id
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
data = rows.fetchall()
|
||||
|
||||
if not data:
|
||||
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
|
||||
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer, delimiter=";")
|
||||
writer.writerow(["joined_tg_id", "created_at"])
|
||||
for joined_tg_id, created_at in data:
|
||||
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
|
||||
|
||||
content = buffer.getvalue().encode("utf-8-sig")
|
||||
filename = f"partner_invites_{tg_id}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.schemas.settings import SettingResponse, SettingUpsert
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
|
||||
from core.settings.modes_config import MODES_CONFIG, update_modes_config
|
||||
from core.settings.money_config import MONEY_CONFIG, update_money_config
|
||||
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
|
||||
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConfigUpdatePayload(BaseModel):
|
||||
value: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
async def get_all_settings(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"payments": dict(PAYMENTS_CONFIG),
|
||||
"buttons": dict(BUTTONS_CONFIG),
|
||||
"notifications": dict(NOTIFICATIONS_CONFIG),
|
||||
"modes": dict(MODES_CONFIG),
|
||||
"money": dict(MONEY_CONFIG),
|
||||
"providers_order": dict(PROVIDERS_ORDER),
|
||||
"tariffs": dict(TARIFFS_CONFIG),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/configs/{scope}")
|
||||
async def update_config_scope(
|
||||
scope: str,
|
||||
payload: ConfigUpdatePayload,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = dict(payload.value or {})
|
||||
normalized = scope.strip().lower().replace("-", "_")
|
||||
|
||||
if normalized == "payments":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_payments_config(session, cleaned)
|
||||
return {"payments": dict(PAYMENTS_CONFIG)}
|
||||
|
||||
if normalized == "buttons":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_buttons_config(session, cleaned)
|
||||
return {"buttons": dict(BUTTONS_CONFIG)}
|
||||
|
||||
if normalized == "notifications":
|
||||
await update_notifications_config(session, data)
|
||||
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
|
||||
|
||||
if normalized == "modes":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_modes_config(session, cleaned)
|
||||
return {"modes": dict(MODES_CONFIG)}
|
||||
|
||||
if normalized == "money":
|
||||
await update_money_config(session, data)
|
||||
return {"money": dict(MONEY_CONFIG)}
|
||||
|
||||
if normalized == "providers_order":
|
||||
cleaned: dict[str, int] = {}
|
||||
for key, value in data.items():
|
||||
try:
|
||||
cleaned[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await update_providers_order(session, cleaned)
|
||||
return {"providers_order": dict(PROVIDERS_ORDER)}
|
||||
|
||||
if normalized == "tariffs":
|
||||
cleaned = dict(data)
|
||||
if "ALLOW_DOWNGRADE" in cleaned:
|
||||
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
|
||||
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
|
||||
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
|
||||
if "KEY_ADDONS_PACK_MODE" in cleaned:
|
||||
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
|
||||
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
|
||||
await update_tariffs_config(session, cleaned)
|
||||
return {"tariffs": dict(TARIFFS_CONFIG)}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Unsupported config scope")
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting_by_key(
|
||||
key: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/{key}", response_model=SettingResponse)
|
||||
async def upsert_setting(
|
||||
key: str,
|
||||
payload: SettingUpsert,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
obj = await set_setting(
|
||||
session=session,
|
||||
key=key,
|
||||
value=payload.value,
|
||||
description=payload.description,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=dict)
|
||||
async def delete_setting(
|
||||
key: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"detail": "Setting deleted"}
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ from .misc import (
|
||||
)
|
||||
from .referrals import ReferralResponse
|
||||
from .servers import ServerBase, ServerResponse, ServerUpdate
|
||||
from .settings import SettingResponse, SettingUpsert
|
||||
from .tariffs import TariffBase, TariffResponse, TariffUpdate
|
||||
from .users import UserBase, UserResponse, UserUpdate
|
||||
|
||||
+51
-10
@@ -11,15 +11,34 @@ class CouponBase(BaseModel):
|
||||
is_used: bool = False
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
percent: int | None = Field(default=None)
|
||||
max_discount_amount: int | None = Field(default=None)
|
||||
min_order_amount: int | None = Field(default=None)
|
||||
|
||||
new_users_only: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
|
||||
def check_coupon_type(self) -> "CouponBase":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided != 1:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -38,12 +57,34 @@ class CouponUpdate(BaseModel):
|
||||
is_used: bool | None = None
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
percent: int | None = None
|
||||
max_discount_amount: int | None = None
|
||||
min_order_amount: int | None = None
|
||||
|
||||
new_users_only: bool | None = 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'")
|
||||
def validate_coupon_update(self) -> "CouponUpdate":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided > 1:
|
||||
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ class KeyBase(BaseModel):
|
||||
notified: bool | None = False
|
||||
notified_24h: bool | None = False
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
|
||||
class KeyResponse(KeyBase):
|
||||
class Config:
|
||||
@@ -41,6 +48,13 @@ class KeyDetailsResponse(BaseModel):
|
||||
location_name: str | None
|
||||
tariff_id: int | None
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -57,6 +71,13 @@ class KeyUpdate(BaseModel):
|
||||
notified: bool | None = None
|
||||
notified_24h: bool | None = None
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SettingUpsert(BaseModel):
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
key: str
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -13,6 +14,19 @@ class TariffBase(BaseModel):
|
||||
is_active: bool = True
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
vless: bool = False
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool = False
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, int] | None = None
|
||||
|
||||
|
||||
class TariffResponse(TariffBase):
|
||||
@@ -34,6 +48,19 @@ class TariffUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
vless: bool | None = None
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool | None = None
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, int] | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -10,8 +10,8 @@ class UserBase(BaseModel):
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
is_bot: bool | None = False
|
||||
balance: float = 0.0
|
||||
trial: int = 0
|
||||
balance: float | None = 0.0
|
||||
trial: int | None = 0
|
||||
source_code: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import ExceptionTypeFilter
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from database import async_session_maker
|
||||
from config import API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
|
||||
from utils.custom_emojis import initialize_custom_emojis
|
||||
from utils.errors import setup_error_handlers
|
||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||
|
||||
apply_button_icons_patch()
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
@@ -24,128 +20,12 @@ dp.include_router(modules_hub)
|
||||
|
||||
load_modules_from_folder()
|
||||
|
||||
from handlers.buttons import BUTTON_ICON_CONFIG
|
||||
|
||||
set_button_icon_config(BUTTON_ICON_CONFIG)
|
||||
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
@dp.errors(ExceptionTypeFilter(Exception))
|
||||
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
||||
if isinstance(event.exception, TelegramForbiddenError):
|
||||
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||
return True
|
||||
|
||||
if isinstance(event.exception, TelegramBadRequest):
|
||||
error_message = str(event.exception)
|
||||
|
||||
if (
|
||||
"query is too old and response timeout expired or query ID is invalid" in error_message
|
||||
or "message can't be deleted for everyone" in error_message
|
||||
or "message to delete not found" in error_message
|
||||
):
|
||||
try:
|
||||
tb = "".join(
|
||||
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
|
||||
)
|
||||
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
|
||||
logger.error(f"Traceback:\n{tb}")
|
||||
|
||||
if ADMIN_ID:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
chat_id=admin_id,
|
||||
document=BufferedInputFile(
|
||||
tb.encode(),
|
||||
filename=f"error_{event.update.update_id}.txt",
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
from handlers.start import start_entry
|
||||
|
||||
if event.update.message:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.message.chat.id,
|
||||
user_id=event.update.message.from_user.id,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.message,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.callback_query.message.chat.id,
|
||||
user_id=event.update.callback_query.from_user.id,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||
|
||||
if not ADMIN_ID:
|
||||
return True
|
||||
|
||||
try:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
chat_id=admin_id,
|
||||
document=BufferedInputFile(
|
||||
traceback.format_exc().encode(),
|
||||
filename=f"error_{event.update.update_id}.txt",
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||
)
|
||||
|
||||
if event.update.message:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.message.chat.id,
|
||||
user_id=event.update.message.from_user.id,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.message,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.callback_query.message.chat.id,
|
||||
user_id=event.update.callback_query.from_user.id,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
|
||||
except TelegramBadRequest as exception:
|
||||
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
||||
except Exception as exception:
|
||||
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
||||
|
||||
return True
|
||||
setup_error_handlers(dp)
|
||||
initialize_custom_emojis()
|
||||
|
||||
Executable → Regular
+176
-82
@@ -58,6 +58,56 @@ BACK_DIR = os.path.expanduser("~/.solobot_backups")
|
||||
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
IS_ROOT_DIR = PROJECT_DIR == "/root"
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
|
||||
def is_ascii_only(value: str) -> bool:
|
||||
"""Проверка, что строка содержит только ASCII."""
|
||||
return all(ord(ch) < 128 for ch in value)
|
||||
|
||||
|
||||
def _parse_tag_version(tag_name: str) -> tuple[int, ...]:
|
||||
"""Извлекает кортеж (major, minor, patch, ...) из тега для сортировки. v.5.1 -> (5, 1), v4 -> (4, 0)."""
|
||||
s = tag_name.strip().lstrip("v.")
|
||||
parts = []
|
||||
for part in re.split(r"[.\s]+", s):
|
||||
try:
|
||||
parts.append(int(part))
|
||||
except ValueError:
|
||||
break
|
||||
return tuple(parts) if parts else (0,)
|
||||
|
||||
|
||||
def warn_english_only():
|
||||
"""Предупреждение о необходимости английской раскладки."""
|
||||
console.print("[red]Обнаружен ввод с неанглийской раскладкой.[/red]")
|
||||
console.print("[yellow]Пожалуйста, переключите раскладку на ENG и введите снова.[/yellow]")
|
||||
|
||||
|
||||
def safe_confirm(message: str, **kwargs) -> bool:
|
||||
"""Безопасный Confirm.ask с защитой от русской раскладки."""
|
||||
while True:
|
||||
try:
|
||||
result = Confirm.ask(message, **kwargs)
|
||||
return result
|
||||
except UnicodeDecodeError:
|
||||
warn_english_only()
|
||||
|
||||
|
||||
def safe_prompt(message: str, **kwargs) -> str:
|
||||
"""Безопасный Prompt.ask с защитой от русской раскладки."""
|
||||
while True:
|
||||
try:
|
||||
value = Prompt.ask(message, **kwargs)
|
||||
except UnicodeDecodeError:
|
||||
warn_english_only()
|
||||
continue
|
||||
if isinstance(value, str) and not is_ascii_only(value):
|
||||
warn_english_only()
|
||||
continue
|
||||
return value
|
||||
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[bold red]КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
@@ -66,8 +116,6 @@ if IS_ROOT_DIR:
|
||||
console.print("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
|
||||
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||
sys.exit(1)
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
|
||||
def is_service_exists(service_name):
|
||||
@@ -154,7 +202,7 @@ def restore_from_backup():
|
||||
shown.append((idx, path))
|
||||
|
||||
try:
|
||||
choice = Prompt.ask(
|
||||
choice = safe_prompt(
|
||||
"[bold blue]Выберите номер бэкапа[/bold blue]",
|
||||
choices=[str(i) for i, _ in shown],
|
||||
)
|
||||
@@ -164,7 +212,7 @@ def restore_from_backup():
|
||||
sel_path = shown[int(choice) - 1][1]
|
||||
|
||||
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
|
||||
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
|
||||
if not safe_confirm("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
|
||||
return
|
||||
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
@@ -174,7 +222,10 @@ def restore_from_backup():
|
||||
install_rsync_if_needed()
|
||||
|
||||
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
|
||||
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
|
||||
rc = subprocess.run(
|
||||
["rsync", "-a", "--delete", f"{sel_path}/", f"{PROJECT_DIR}/"],
|
||||
check=False,
|
||||
).returncode
|
||||
if rc != 0:
|
||||
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
|
||||
return
|
||||
@@ -203,7 +254,7 @@ def auto_update_cli():
|
||||
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
|
||||
with open(current_path, "w", encoding="utf-8") as f:
|
||||
f.write(latest_text)
|
||||
os.chmod(current_path, 0o755)
|
||||
os.chmod(current_path, 0o644)
|
||||
console.print("[green]CLI обновлён. Перезапуск...[/green]")
|
||||
os.execv(sys.executable, [sys.executable, current_path])
|
||||
else:
|
||||
@@ -334,13 +385,13 @@ def install_dependencies():
|
||||
shutil.rmtree("venv")
|
||||
console.print("[yellow]Удалён старый venv[/yellow]")
|
||||
|
||||
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
|
||||
subprocess.run([python312_path, "-m", "venv", "venv"], check=True)
|
||||
|
||||
progress.update(task_id, description="Установка зависимостей...")
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
|
||||
shell=True,
|
||||
[os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"],
|
||||
check=True,
|
||||
cwd=PROJECT_DIR,
|
||||
)
|
||||
|
||||
progress.update(task_id, description="Установка завершена")
|
||||
@@ -354,7 +405,7 @@ def restart_service():
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
console.print("[blue]🚀 Перезапуск службы...[/blue]")
|
||||
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
|
||||
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
|
||||
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
|
||||
@@ -389,21 +440,40 @@ def update_from_beta():
|
||||
local_version = get_local_version()
|
||||
remote_version = get_remote_version(branch="dev")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]Обновление на DEV / BETA-ветку[/bold red]\n\n"
|
||||
"[white]"
|
||||
"• Dev-ветка может содержать изменения, которые ещё находятся в доработке.\n"
|
||||
"• Возможны ошибки и непредсказуемое поведение отдельных функций, особенно режима стран.\n\n"
|
||||
"• BETA-версии бота в первую очередь ориентированы на опытных пользователей, "
|
||||
"готовых протестировать новые возможности и осознанно работать с обновлённым функционалом.\n"
|
||||
"[/white]\n\n"
|
||||
"[yellow]Перед началом обновления CLI автоматически создаёт резервную копию проекта, "
|
||||
"что позволит при необходимости безопасно восстановиться из бэкапа.[/yellow]",
|
||||
border_style="red",
|
||||
title="[bold red]Нестабильная ветка разработки[/bold red]",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
if local_version and remote_version:
|
||||
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
|
||||
if local_version == remote_version:
|
||||
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
if not safe_confirm("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
return
|
||||
|
||||
if not Confirm.ask("[yellow]Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
if not safe_confirm(
|
||||
"[bold red]Продолжить обновление на dev-ветку с учётом возможных особенностей работы?[/bold red]"
|
||||
):
|
||||
return
|
||||
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]Продолжить обновление?[/red]"):
|
||||
if not safe_confirm("[red]Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
@@ -427,7 +497,8 @@ def update_from_beta():
|
||||
exclude_options += "--exclude=handlers/buttons.py "
|
||||
exclude_options += "--exclude=modules "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
|
||||
subprocess.run(rsync_cmd)
|
||||
|
||||
modules_path = os.path.join(PROJECT_DIR, "modules")
|
||||
if not os.path.exists(modules_path):
|
||||
@@ -449,82 +520,105 @@ def update_from_beta():
|
||||
console.print("[green]Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
def _do_update_to_tag(tag_name: str, update_buttons: bool, update_img: bool) -> None:
|
||||
"""Общая логика обновления до указанного тега (релиз или произвольный тег)."""
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(
|
||||
["git", "clone", "--branch", tag_name, "--depth", "1", GITHUB_REPO, TEMP_DIR],
|
||||
check=True,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
exclude_options = ""
|
||||
if not update_img:
|
||||
exclude_options += "--exclude=img "
|
||||
if not update_buttons:
|
||||
exclude_options += "--exclude=handlers/buttons.py "
|
||||
exclude_options += "--exclude=modules "
|
||||
|
||||
rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
|
||||
subprocess.run(rsync_cmd)
|
||||
|
||||
modules_path = os.path.join(PROJECT_DIR, "modules")
|
||||
if not os.path.exists(modules_path):
|
||||
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
|
||||
try:
|
||||
os.makedirs(modules_path, exist_ok=True)
|
||||
console.print("[green]Папка modules успешно создана.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
|
||||
|
||||
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])
|
||||
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print(f"[green]Обновление до {tag_name} завершено.[/green]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
if not Confirm.ask("[yellow]Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
|
||||
if not safe_confirm("[yellow]Подтвердите обновление Solobot до релиза или патча[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||
if not Confirm.ask("[red]Вы точно хотите продолжить?[/red]"):
|
||||
if not safe_confirm("[red]Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = safe_confirm("[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)
|
||||
releases = response.json()[:3]
|
||||
tag_choices = [r["tag_name"] for r in releases]
|
||||
|
||||
if not tag_choices:
|
||||
raise ValueError("Не удалось получить список релизов")
|
||||
|
||||
console.print("\n[bold green]Доступные релизы:[/bold green]")
|
||||
for idx, tag in enumerate(tag_choices, 1):
|
||||
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||
|
||||
selected = Prompt.ask(
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
|
||||
rel_resp = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/releases",
|
||||
timeout=10,
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
releases = rel_resp.json() if rel_resp.status_code == 200 else []
|
||||
release_tag_names = {r["tag_name"] for r in releases}
|
||||
|
||||
if not Confirm.ask(f"[yellow]Подтвердите установку релиза {tag_name}[/yellow]"):
|
||||
tags_resp = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/tags",
|
||||
params={"per_page": 50},
|
||||
timeout=10,
|
||||
)
|
||||
if tags_resp.status_code != 200:
|
||||
raise ValueError("Не удалось получить список тегов")
|
||||
tags_data = tags_resp.json()
|
||||
all_tag_names = [t["name"] for t in tags_data]
|
||||
|
||||
tag_names = [name for name in all_tag_names if _parse_tag_version(name)[0] >= 4]
|
||||
tag_names.sort(key=_parse_tag_version)
|
||||
|
||||
if not tag_names:
|
||||
raise ValueError("Нет доступных тегов (ожидаются версии начиная с 4)")
|
||||
|
||||
console.print("\n[bold green]Релизы и патчи:[/bold green]")
|
||||
for idx, name in enumerate(tag_names, 1):
|
||||
label = " [dim](релиз)[/dim]" if name in release_tag_names else " [dim](патч)[/dim]"
|
||||
console.print(f"[cyan]{idx}.[/cyan] {name}{label}")
|
||||
|
||||
choices = [str(i) for i in range(1, len(tag_names) + 1)]
|
||||
selected = safe_prompt(
|
||||
"[bold blue]Выберите номер версии[/bold blue]",
|
||||
choices=choices,
|
||||
)
|
||||
tag_name = tag_names[int(selected) - 1]
|
||||
|
||||
if not safe_confirm(f"[yellow]Установить {tag_name}?[/yellow]"):
|
||||
return
|
||||
|
||||
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(
|
||||
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
exclude_options = ""
|
||||
if not update_img:
|
||||
exclude_options += "--exclude=img "
|
||||
if not update_buttons:
|
||||
exclude_options += "--exclude=handlers/buttons.py "
|
||||
exclude_options += "--exclude=modules "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
|
||||
modules_path = os.path.join(PROJECT_DIR, "modules")
|
||||
if not os.path.exists(modules_path):
|
||||
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
|
||||
try:
|
||||
os.makedirs(modules_path, exist_ok=True)
|
||||
console.print("[green]Папка modules успешно создана.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
|
||||
|
||||
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])
|
||||
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
|
||||
console.print(f"[cyan]Клонируем {tag_name} во временную папку...[/cyan]")
|
||||
_do_update_to_tag(tag_name, update_buttons, update_img)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
|
||||
@@ -540,11 +634,11 @@ def show_update_menu():
|
||||
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)
|
||||
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
|
||||
choice = safe_prompt("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
|
||||
|
||||
if choice == "1":
|
||||
update_from_beta()
|
||||
@@ -553,7 +647,7 @@ def show_update_menu():
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(title="Solobot CLI v0.3.3", title_style="bold magenta", header_style="bold blue")
|
||||
table = Table(title="Solobot CLI v0.4.0", title_style="bold magenta", header_style="bold blue")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Операция", style="white")
|
||||
table.add_row("1", "Запустить бота (systemd)")
|
||||
@@ -575,7 +669,7 @@ def main():
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask(
|
||||
choice = safe_prompt(
|
||||
"[bold blue]👉 Введите номер действия[/bold blue]",
|
||||
choices=[str(i) for i in range(1, 10)],
|
||||
show_choices=False,
|
||||
@@ -586,17 +680,17 @@ def main():
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "2":
|
||||
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
if safe_confirm("[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 safe_confirm("[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 safe_confirm("[red]Вы уверены, что хотите остановить бота?[/red]"):
|
||||
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
from database import async_session_maker
|
||||
from database.db import warm_pool
|
||||
from database.tariffs import initialize_all_tariff_weights
|
||||
|
||||
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
|
||||
from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
|
||||
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
|
||||
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
|
||||
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
|
||||
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
|
||||
|
||||
|
||||
async def bootstrap() -> None:
|
||||
await warm_pool()
|
||||
async with async_session_maker() as session:
|
||||
await initialize_all_tariff_weights(session)
|
||||
await load_buttons_config(session)
|
||||
await load_notifications_config(session)
|
||||
await load_modes_config(session)
|
||||
await load_payments_config(session)
|
||||
await load_providers_order(session)
|
||||
await load_money_config(session)
|
||||
await load_management_config(session)
|
||||
await load_tariffs_config(session)
|
||||
await session.commit()
|
||||
@@ -0,0 +1 @@
|
||||
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_BUTTONS_CONFIG
|
||||
|
||||
|
||||
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
|
||||
|
||||
async def load_buttons_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting = Setting(
|
||||
key="BUTTONS_CONFIG",
|
||||
value=buttons_config,
|
||||
description="Конфигурация кнопок бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(stored)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting.value = buttons_config
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_buttons_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="BUTTONS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация кнопок бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(new_values)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
|
||||
|
||||
|
||||
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
|
||||
|
||||
|
||||
async def load_management_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=management_config,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
management_config.update(stored)
|
||||
setting.value = management_config
|
||||
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=new_values,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
management_config.update(new_values)
|
||||
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MODES_CONFIG
|
||||
|
||||
|
||||
MODES_CONFIG: dict[str, bool] = DEFAULT_MODES_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_modes_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="MODES_CONFIG",
|
||||
value=modes_config,
|
||||
description="Конфигурация режимов работы бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
modes_config.update(stored)
|
||||
setting.value = modes_config
|
||||
|
||||
MODES_CONFIG.clear()
|
||||
MODES_CONFIG.update(modes_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="MODES_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация режимов работы бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
modes_config.update(new_values)
|
||||
|
||||
MODES_CONFIG.clear()
|
||||
MODES_CONFIG.update(modes_config)
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MONEY_CONFIG
|
||||
|
||||
|
||||
MONEY_CONFIG: dict[str, Any] = DEFAULT_MONEY_CONFIG.copy()
|
||||
|
||||
|
||||
def get_currency_mode() -> tuple[str, bool]:
|
||||
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
|
||||
raw = str(mode_cfg or "RUB").upper()
|
||||
|
||||
if raw not in ("RUB", "USD", "RUB+USD", "RUB+USD_ONE_SCREEN"):
|
||||
raw = "RUB"
|
||||
|
||||
one_screen = raw == "RUB+USD_ONE_SCREEN"
|
||||
if raw in ("RUB+USD", "RUB+USD_ONE_SCREEN"):
|
||||
base_mode = "RUB+USD"
|
||||
else:
|
||||
base_mode = raw
|
||||
|
||||
return base_mode, one_screen
|
||||
|
||||
|
||||
async def load_money_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="MONEY_CONFIG",
|
||||
value=money_config,
|
||||
description="Конфигурация валютных настроек",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
money_config.update(stored)
|
||||
setting.value = money_config
|
||||
|
||||
MONEY_CONFIG.clear()
|
||||
MONEY_CONFIG.update(money_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="MONEY_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация валютных настроек",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
money_config.update(new_values)
|
||||
|
||||
MONEY_CONFIG.clear()
|
||||
MONEY_CONFIG.update(money_config)
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
|
||||
|
||||
|
||||
NOTIFICATIONS_CONFIG: dict[str, Any] = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_notifications_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="NOTIFICATIONS_CONFIG",
|
||||
value=notifications_config,
|
||||
description="Конфигурация уведомлений",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
notifications_config.update(stored)
|
||||
setting.value = notifications_config
|
||||
|
||||
NOTIFICATIONS_CONFIG.clear()
|
||||
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_notifications_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="NOTIFICATIONS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация уведомлений",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
notifications_config.update(new_values)
|
||||
|
||||
NOTIFICATIONS_CONFIG.clear()
|
||||
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_PAYMENTS_CONFIG
|
||||
|
||||
|
||||
PAYMENTS_CONFIG: dict[str, bool] = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_payments_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="PAYMENTS_CONFIG",
|
||||
value=payments_config,
|
||||
description="Конфигурация платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
payments_config.update(stored)
|
||||
setting.value = payments_config
|
||||
|
||||
PAYMENTS_CONFIG.clear()
|
||||
PAYMENTS_CONFIG.update(payments_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_payments_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="PAYMENTS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
payments_config.update(new_values)
|
||||
|
||||
PAYMENTS_CONFIG.clear()
|
||||
PAYMENTS_CONFIG.update(payments_config)
|
||||
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
PROVIDERS_ORDER: dict[str, int] = {}
|
||||
|
||||
|
||||
async def load_providers_order(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
PROVIDERS_ORDER.clear()
|
||||
if setting and isinstance(setting.value, dict):
|
||||
PROVIDERS_ORDER.update({k: int(v) for k, v in setting.value.items()})
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_providers_order(session: AsyncSession, new_order: dict[str, int]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="PROVIDERS_ORDER",
|
||||
value=new_order,
|
||||
description="Порядок отображения платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_order
|
||||
|
||||
await session.commit()
|
||||
|
||||
PROVIDERS_ORDER.clear()
|
||||
PROVIDERS_ORDER.update(new_order)
|
||||
@@ -0,0 +1,193 @@
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
|
||||
TARIFFS_CONFIG: dict[str, Any] = {
|
||||
"ALLOW_DOWNGRADE": True,
|
||||
"KEY_ADDONS_PACK_MODE": "all",
|
||||
"KEY_ADDONS_PRICE_BASE_MODE": "current",
|
||||
}
|
||||
|
||||
|
||||
async def load_tariffs_config(session: AsyncSession) -> None:
|
||||
"""Загружает конфиг тарифов из БД."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=tariffs_config,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(stored)
|
||||
setting.value = tariffs_config
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
"""Обновляет конфиг тарифов."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(new_values)
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
|
||||
|
||||
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
|
||||
base_device_limit = int(tariff.get("device_limit") or 1)
|
||||
extra_devices = max(0, device_limit - base_device_limit)
|
||||
if extra_devices <= 0:
|
||||
return 0
|
||||
|
||||
step_price = int(tariff.get("device_step_rub") or 0)
|
||||
overrides = tariff.get("device_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(device_limit))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
return int(ceil(extra_devices * step_price))
|
||||
|
||||
|
||||
def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
|
||||
if traffic_gb is None:
|
||||
return 0
|
||||
|
||||
traffic_limit_bytes = tariff.get("traffic_limit")
|
||||
if traffic_limit_bytes:
|
||||
base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
|
||||
else:
|
||||
base_traffic_gb = 0
|
||||
|
||||
step_price = int(tariff.get("traffic_step_rub") or 0)
|
||||
overrides = tariff.get("traffic_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(traffic_gb))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
if traffic_gb == 0:
|
||||
return 0
|
||||
|
||||
extra_gb = max(0, traffic_gb - base_traffic_gb)
|
||||
if extra_gb <= 0:
|
||||
return 0
|
||||
|
||||
return int(ceil(extra_gb * step_price))
|
||||
|
||||
|
||||
def calculate_config_price(
|
||||
tariff: dict[str, Any],
|
||||
duration_days: int,
|
||||
device_limit: int,
|
||||
traffic_gb: int | None,
|
||||
) -> int:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
|
||||
if base_duration <= 0:
|
||||
base_duration = duration_days or 30
|
||||
|
||||
base_price = int(tariff.get("price_rub") or 0)
|
||||
|
||||
duration_multiplier = duration_days / base_duration
|
||||
base_price_scaled = base_price * duration_multiplier
|
||||
|
||||
extra_devices_price = calc_extra_devices_price(tariff, device_limit)
|
||||
extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
|
||||
|
||||
total = base_price_scaled + extra_devices_price + extra_traffic_price
|
||||
return int(ceil(total))
|
||||
|
||||
|
||||
def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_duration_options = tariff.get("duration_options") or []
|
||||
duration_options: list[int] = []
|
||||
for value in raw_duration_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
duration_options.append(v)
|
||||
if not duration_options:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or 30
|
||||
duration_options = [base_duration]
|
||||
duration_options = sorted(set(duration_options))
|
||||
|
||||
raw_device_options = tariff.get("device_options") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
device_options.append(v)
|
||||
if not device_options:
|
||||
base_device_limit = int(tariff.get("device_limit") or 0)
|
||||
if base_device_limit > 0:
|
||||
device_options = [base_device_limit]
|
||||
else:
|
||||
device_options = []
|
||||
device_options = sorted(set(device_options))
|
||||
|
||||
raw_traffic_options = tariff.get("traffic_options_gb")
|
||||
traffic_options_gb: list[int] | None
|
||||
if raw_traffic_options is None:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
traffic_values: list[int] = []
|
||||
has_unlimited = False
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v == 0:
|
||||
has_unlimited = True
|
||||
elif v > 0:
|
||||
traffic_values.append(v)
|
||||
if not traffic_values and not has_unlimited:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
unique_values = sorted(set(traffic_values))
|
||||
if has_unlimited:
|
||||
traffic_options_gb = [0] + unique_values
|
||||
else:
|
||||
traffic_options_gb = unique_values
|
||||
|
||||
return {
|
||||
"duration_options": duration_options,
|
||||
"device_options": device_options,
|
||||
"traffic_options_gb": traffic_options_gb,
|
||||
}
|
||||
+53
-2
@@ -8,21 +8,50 @@ 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 | None,
|
||||
usage_limit: int,
|
||||
days: int | None = None,
|
||||
new_users_only: bool = False,
|
||||
percent: int | None = None,
|
||||
max_discount_amount: int | None = None,
|
||||
min_order_amount: int | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||
if exists:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
|
||||
return False
|
||||
|
||||
if percent is not None:
|
||||
try:
|
||||
percent_value = int(percent)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
|
||||
return False
|
||||
|
||||
if percent_value <= 0 or percent_value > 100:
|
||||
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
|
||||
return False
|
||||
|
||||
if (amount or 0) > 0 or (days or 0) > 0:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
|
||||
return False
|
||||
|
||||
await session.execute(
|
||||
insert(Coupon).values(
|
||||
code=code,
|
||||
amount=amount,
|
||||
amount=int(amount) if amount is not None else 0,
|
||||
usage_limit=usage_limit,
|
||||
usage_count=0,
|
||||
is_used=False,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
percent=percent,
|
||||
max_discount_amount=max_discount_amount,
|
||||
min_order_amount=min_order_amount,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -84,6 +113,7 @@ async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: in
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def check_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int) -> bool:
|
||||
@@ -107,3 +137,24 @@ async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
|
||||
percent = coupon.percent
|
||||
if percent is None:
|
||||
return price_rub, 0
|
||||
|
||||
if coupon.min_order_amount is not None and price_rub < int(coupon.min_order_amount):
|
||||
return price_rub, 0
|
||||
|
||||
discount = (price_rub * int(percent)) // 100
|
||||
|
||||
if coupon.max_discount_amount is not None:
|
||||
discount = min(discount, int(coupon.max_discount_amount))
|
||||
|
||||
final_price = price_rub - discount
|
||||
if final_price < 0:
|
||||
final_price = 0
|
||||
|
||||
return final_price, discount
|
||||
|
||||
+42
-3
@@ -1,11 +1,50 @@
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from config import DATABASE_URL
|
||||
from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE
|
||||
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True, pool_size=20, max_overflow=30, pool_timeout=15)
|
||||
CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW
|
||||
MAX_UPDATE_AGE_SEC = 28
|
||||
CONCURRENT_UPDATES_WAIT_TIMEOUT_SEC = 8
|
||||
CONCURRENT_UPDATES_GATE_LIMIT = 150
|
||||
CONCURRENT_UPDATES_GATE_WAIT_SEC = 2
|
||||
|
||||
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=DB_POOL_SIZE,
|
||||
max_overflow=DB_MAX_OVERFLOW,
|
||||
pool_timeout=60,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
WARM_POOL_COUNT = 10
|
||||
|
||||
|
||||
async def warm_pool() -> None:
|
||||
"""
|
||||
Прогревает пул соединений при старте.
|
||||
"""
|
||||
|
||||
async def _one() -> None:
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
|
||||
count = min(WARM_POOL_COUNT, DB_POOL_SIZE)
|
||||
if count <= 0:
|
||||
return
|
||||
await asyncio.gather(*[asyncio.create_task(_one()) for _ in range(count)])
|
||||
|
||||
+12
-2
@@ -18,6 +18,9 @@ async def store_gift_link(
|
||||
tariff_id: int | None = None,
|
||||
is_unlimited: bool = False,
|
||||
max_usages: int | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
):
|
||||
try:
|
||||
stmt = insert(Gift).values(
|
||||
@@ -32,12 +35,19 @@ async def store_gift_link(
|
||||
tariff_id=tariff_id,
|
||||
is_unlimited=is_unlimited,
|
||||
max_usages=max_usages,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
selected_price_rub=selected_price_rub,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id}, max_usages={max_usages})")
|
||||
logger.info(
|
||||
f"🎁 Подарок {gift_id} сохранён "
|
||||
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
|
||||
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
|
||||
)
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, User
|
||||
|
||||
|
||||
@@ -16,7 +17,7 @@ async def get_hot_leads(session: AsyncSession):
|
||||
.where(User.trial == 1)
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
.where(~Payment.tg_id.in_(sub_active))
|
||||
)
|
||||
|
||||
|
||||
+10
-5
@@ -10,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import USE_COUNTRY_SELECTION
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database.models import Key, Server, User
|
||||
|
||||
|
||||
@@ -17,7 +18,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if use_country_selection:
|
||||
result = await session.execute(
|
||||
select(Server.server_name).where(Server.enabled.is_(True), Server.panel_type == "3x-ui")
|
||||
)
|
||||
@@ -89,8 +92,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
continue
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
raise RuntimeError(f"Ошибка при импорте пользователя tg_id={tg_id}") from e
|
||||
|
||||
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if key_exists.scalar():
|
||||
@@ -116,8 +120,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
except SQLAlchemyError:
|
||||
continue
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
raise RuntimeError(f"Ошибка при импорте ключа client_id={client_id}") from e
|
||||
|
||||
await session.commit()
|
||||
return imported, skipped
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import select
|
||||
from config import ADMIN_ID
|
||||
from database.db import async_session_maker, engine
|
||||
from database.models import Admin, Base, User
|
||||
from database.tariffs import initialize_all_tariff_weights
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -35,5 +34,3 @@ async def init_db():
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await initialize_all_tariff_weights(session)
|
||||
|
||||
+110
-22
@@ -17,29 +17,49 @@ async def store_key(
|
||||
key: str,
|
||||
server_id: str,
|
||||
remnawave_link: str = None,
|
||||
tariff_id: int = None,
|
||||
alias: str = None,
|
||||
tariff_id: int | None = None,
|
||||
alias: str | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_limit: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
current_device_limit: int | None = None,
|
||||
current_traffic_limit: int | None = None,
|
||||
):
|
||||
"""Сохраняет или обновляет ключ подписки."""
|
||||
try:
|
||||
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
||||
existing_key = exists.scalar_one_or_none()
|
||||
|
||||
|
||||
if existing_key:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
.values(
|
||||
email=email,
|
||||
expiry_time=expiry_time,
|
||||
key=key,
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
)
|
||||
)
|
||||
values: dict = {
|
||||
"email": email,
|
||||
"expiry_time": expiry_time,
|
||||
"key": key,
|
||||
"server_id": server_id,
|
||||
"remnawave_link": remnawave_link,
|
||||
"tariff_id": tariff_id,
|
||||
"alias": alias,
|
||||
}
|
||||
|
||||
if selected_device_limit is not None:
|
||||
values["selected_device_limit"] = selected_device_limit
|
||||
if selected_traffic_limit is not None:
|
||||
values["selected_traffic_limit"] = selected_traffic_limit
|
||||
if selected_price_rub is not None:
|
||||
values["selected_price_rub"] = selected_price_rub
|
||||
if current_device_limit is not None:
|
||||
values["current_device_limit"] = current_device_limit
|
||||
if current_traffic_limit is not None:
|
||||
values["current_traffic_limit"] = current_traffic_limit
|
||||
|
||||
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
|
||||
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
else:
|
||||
if current_device_limit is None:
|
||||
current_device_limit = selected_device_limit
|
||||
if current_traffic_limit is None:
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
@@ -51,15 +71,21 @@ async def store_key(
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_limit=selected_traffic_limit,
|
||||
selected_price_rub=selected_price_rub,
|
||||
current_device_limit=current_device_limit,
|
||||
current_traffic_limit=current_traffic_limit,
|
||||
)
|
||||
session.add(new_key)
|
||||
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
|
||||
|
||||
await session.commit()
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_keys(session: AsyncSession, tg_id: int):
|
||||
@@ -79,6 +105,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:
|
||||
"""Возвращает подробную информацию о ключе по 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()
|
||||
@@ -116,6 +143,11 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
"cluster_name": key.server_id,
|
||||
"location_name": key.server_id,
|
||||
"tariff_id": key.tariff_id,
|
||||
"selected_device_limit": key.selected_device_limit,
|
||||
"selected_traffic_limit": key.selected_traffic_limit,
|
||||
"selected_price_rub": key.selected_price_rub,
|
||||
"current_device_limit": key.current_device_limit,
|
||||
"current_traffic_limit": key.current_traffic_limit,
|
||||
}
|
||||
|
||||
|
||||
@@ -124,10 +156,11 @@ async def get_key_count(session: AsyncSession, tg_id: int) -> int:
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def delete_key(session: AsyncSession, identifier: int | str):
|
||||
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
|
||||
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
if commit:
|
||||
await session.commit()
|
||||
logger.info(f"Ключ с идентификатором {identifier} удалён")
|
||||
|
||||
|
||||
@@ -156,13 +189,18 @@ async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
"""
|
||||
),
|
||||
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
"""
|
||||
@@ -171,7 +209,7 @@ async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
"""
|
||||
),
|
||||
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
@@ -199,3 +237,53 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
|
||||
res = await session.execute(q)
|
||||
await session.commit()
|
||||
return res.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def save_key_config_with_mode(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
selected_devices: int | None,
|
||||
selected_traffic_gb: int | None,
|
||||
total_price: int,
|
||||
has_device_choice: bool,
|
||||
has_traffic_choice: bool,
|
||||
config_mode: str,
|
||||
) -> None:
|
||||
values: dict = {}
|
||||
|
||||
if config_mode == "pack":
|
||||
if has_device_choice and selected_devices is not None:
|
||||
values["current_device_limit"] = int(selected_devices)
|
||||
if has_traffic_choice and selected_traffic_gb is not None:
|
||||
values["current_traffic_limit"] = int(selected_traffic_gb)
|
||||
else:
|
||||
device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
|
||||
traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
|
||||
|
||||
values["selected_device_limit"] = device_val
|
||||
values["selected_traffic_limit"] = traffic_val
|
||||
values["selected_price_rub"] = int(total_price)
|
||||
values["current_device_limit"] = device_val
|
||||
values["current_traffic_limit"] = traffic_val
|
||||
|
||||
if not values:
|
||||
return
|
||||
|
||||
await session.execute(update(Key).where(Key.email == email).values(**values))
|
||||
|
||||
|
||||
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
|
||||
"""Сбрасывает текущие лимиты к выбранным для ключа."""
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET current_device_limit = selected_device_limit,
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
WHERE client_id = :client_id
|
||||
"""
|
||||
),
|
||||
{"client_id": client_id},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
|
||||
@@ -77,6 +78,13 @@ class Key(DictLikeMixin, Base):
|
||||
notified = Column(Boolean, default=False)
|
||||
notified_24h = Column(Boolean, default=False)
|
||||
|
||||
selected_device_limit = Column(Integer, nullable=True)
|
||||
selected_traffic_limit = Column(BigInteger, nullable=True)
|
||||
selected_price_rub = Column(Integer, nullable=True)
|
||||
|
||||
current_device_limit = Column(Integer, nullable=True)
|
||||
current_traffic_limit = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class Tariff(DictLikeMixin, Base):
|
||||
__tablename__ = "tariffs"
|
||||
@@ -94,6 +102,18 @@ class Tariff(DictLikeMixin, Base):
|
||||
subgroup_title = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, nullable=True)
|
||||
vless = Column(Boolean, default=False)
|
||||
external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
configurable = Column(Boolean, nullable=False, server_default="false")
|
||||
|
||||
device_options = Column(JSONB, nullable=True)
|
||||
traffic_options_gb = Column(JSONB, nullable=True)
|
||||
|
||||
device_step_rub = Column(Integer, nullable=True)
|
||||
device_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
traffic_step_rub = Column(Integer, nullable=True)
|
||||
traffic_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
|
||||
class Server(DictLikeMixin, Base):
|
||||
@@ -164,6 +184,11 @@ class Coupon(DictLikeMixin, Base):
|
||||
usage_count = Column(Integer, default=0)
|
||||
is_used = Column(Boolean, default=False)
|
||||
days = Column(Integer, nullable=True)
|
||||
new_users_only = Column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
percent = Column(Integer, nullable=True)
|
||||
max_discount_amount = Column(Integer, nullable=True)
|
||||
min_order_amount = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class CouponUsage(DictLikeMixin, Base):
|
||||
@@ -205,6 +230,10 @@ class Gift(DictLikeMixin, Base):
|
||||
max_usages = Column(Integer, nullable=True)
|
||||
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
|
||||
|
||||
selected_device_limit = Column(Integer, nullable=True)
|
||||
selected_traffic_gb = Column(Integer, nullable=True)
|
||||
selected_price_rub = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class GiftUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "gift_usages"
|
||||
@@ -262,3 +291,13 @@ class Admin(Base):
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
class Setting(DictLikeMixin, Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(JSONB, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DISCOUNT_ACTIVE_HOURS
|
||||
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||
from database.models import Key, Notification, User
|
||||
from logger import logger
|
||||
|
||||
@@ -30,6 +31,7 @@ async def add_notification(session: AsyncSession, tg_id: int, notification_type:
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def delete_notification(session: AsyncSession, tg_id: int, notification_type: str):
|
||||
@@ -81,7 +83,9 @@ async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
|
||||
|
||||
notification_type, last_time = row
|
||||
|
||||
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
|
||||
hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||
|
||||
expires_at = last_time + timedelta(hours=hours)
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if current_time > expires_at:
|
||||
@@ -89,10 +93,16 @@ async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
|
||||
|
||||
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
|
||||
|
||||
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
|
||||
return {
|
||||
"available": True,
|
||||
"type": notification_type,
|
||||
"tariff_group": tariff_group,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
return {"available": False}
|
||||
|
||||
|
||||
@@ -157,7 +167,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}")
|
||||
@@ -165,4 +175,5 @@ async def check_notifications_bulk(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
|
||||
await session.rollback()
|
||||
return []
|
||||
|
||||
+63
-14
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from pytz import timezone
|
||||
from sqlalchemy import and_, insert, select, update
|
||||
@@ -44,7 +43,6 @@ async def add_payment(
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
internal_id = result.scalar_one()
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
|
||||
)
|
||||
@@ -91,13 +89,23 @@ async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | N
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж id={internal_id}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж id={internal_id} не найден")
|
||||
return None
|
||||
if not payment:
|
||||
return None
|
||||
return {
|
||||
"id": payment.id,
|
||||
"tg_id": payment.tg_id,
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"payment_system": payment.payment_system,
|
||||
"payment_id": payment.payment_id,
|
||||
"created_at": payment.created_at,
|
||||
"metadata": payment.metadata_,
|
||||
"original_amount": payment.original_amount,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -137,13 +145,23 @@ async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | N
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж payment_id={pid}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж payment_id={pid} не найден")
|
||||
return None
|
||||
if not payment:
|
||||
return None
|
||||
return {
|
||||
"id": payment.id,
|
||||
"tg_id": payment.tg_id,
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"payment_system": payment.payment_system,
|
||||
"payment_id": payment.payment_id,
|
||||
"created_at": payment.created_at,
|
||||
"metadata": payment.metadata_,
|
||||
"original_amount": payment.original_amount,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -158,8 +176,39 @@ async def cancel_expired_pending_payments(session: AsyncSession) -> int:
|
||||
)
|
||||
)
|
||||
.values(status="cancelled")
|
||||
.returning(Payment.id)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return len(res.fetchall())
|
||||
affected = res.rowcount or 0
|
||||
return affected
|
||||
|
||||
|
||||
async def get_all_payments(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
statuses: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
query = select(Payment).where(Payment.tg_id == tg_id)
|
||||
|
||||
if statuses:
|
||||
query = query.where(Payment.status.in_(statuses))
|
||||
|
||||
query = query.order_by(Payment.created_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
payments = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"tg_id": p.tg_id,
|
||||
"amount": p.amount,
|
||||
"currency": p.currency,
|
||||
"status": p.status,
|
||||
"payment_system": p.payment_system,
|
||||
"payment_id": p.payment_id,
|
||||
"created_at": p.created_at,
|
||||
"metadata": p.metadata_,
|
||||
"original_amount": p.original_amount,
|
||||
}
|
||||
for p in payments
|
||||
]
|
||||
|
||||
+20
-4
@@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database.models import Referral
|
||||
from logger import logger
|
||||
|
||||
@@ -57,6 +58,11 @@ async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int
|
||||
|
||||
|
||||
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
|
||||
referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
|
||||
if not referral_enabled:
|
||||
logger.debug("Реферальная программа отключена, бонусы не начисляются")
|
||||
return 0.0
|
||||
|
||||
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||
bonus_cte = """
|
||||
WITH RECURSIVE
|
||||
@@ -81,7 +87,8 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m
|
||||
earliest_payments AS (
|
||||
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
|
||||
FROM payments
|
||||
WHERE status = 'success'
|
||||
WHERE status = 'success'
|
||||
AND payment_system NOT IN ('coupon', 'admin', 'referral')
|
||||
ORDER BY tg_id, created_at
|
||||
)
|
||||
"""
|
||||
@@ -148,11 +155,16 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m
|
||||
), 0) AS total_bonus
|
||||
FROM referral_levels rl
|
||||
JOIN payments p ON rl.referred_tg_id = p.tg_id
|
||||
WHERE p.status = 'success' AND rl.level <= :max_levels
|
||||
WHERE p.status = 'success'
|
||||
AND p.payment_system NOT IN ('coupon', 'admin', 'referral')
|
||||
AND rl.level <= :max_levels
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels})
|
||||
result = await session.execute(
|
||||
text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
{"tg_id": referrer_tg_id, "max_levels": max_levels},
|
||||
)
|
||||
total_bonus_raw = result.scalar()
|
||||
total_bonus = round(float(total_bonus_raw or 0), 2)
|
||||
|
||||
@@ -180,7 +192,10 @@ async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max
|
||||
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), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
{"referrer_tg_id": referrer_tg_id, "max_levels": max_levels},
|
||||
)
|
||||
return {
|
||||
row["level"]: {
|
||||
"total": row["level_count"],
|
||||
@@ -209,6 +224,7 @@ async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
|
||||
+99
-8
@@ -53,12 +53,16 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
|
||||
|
||||
ids = [s.id for s in servers]
|
||||
subs_map = {}
|
||||
tariffs_map = {}
|
||||
if ids:
|
||||
r = await session.execute(
|
||||
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
|
||||
)
|
||||
for sid, sg in r.all():
|
||||
subs_map.setdefault(sid, []).append(sg)
|
||||
if sg and sg.isdigit():
|
||||
tariffs_map.setdefault(sid, []).append(int(sg))
|
||||
else:
|
||||
subs_map.setdefault(sid, []).append(sg)
|
||||
|
||||
groups_map = {}
|
||||
if ids:
|
||||
@@ -88,12 +92,15 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
|
||||
"max_keys": s.max_keys,
|
||||
"tariff_group": s.tariff_group,
|
||||
"tariff_subgroups": subs_map.get(s.id, []),
|
||||
"tariff_ids": tariffs_map.get(s.id, []),
|
||||
"special_groups": special,
|
||||
"cluster_name": cluster,
|
||||
"server_id": s.id,
|
||||
})
|
||||
return grouped
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении серверов: {e}")
|
||||
await session.rollback()
|
||||
return {}
|
||||
|
||||
|
||||
@@ -118,6 +125,7 @@ async def check_server_name_by_cluster(session: AsyncSession, server_name: str)
|
||||
return {"cluster_name": row[0]} if row else None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -155,6 +163,7 @@ async def get_server_by_name(session: AsyncSession, server_name: str) -> dict |
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -203,6 +212,7 @@ async def get_available_clusters(session: AsyncSession) -> list[str]:
|
||||
return [row[0] for row in result.all()]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении списка кластеров: {e}")
|
||||
await session.rollback()
|
||||
return []
|
||||
|
||||
|
||||
@@ -272,12 +282,32 @@ async def resolve_device_limit_from_group(session: AsyncSession, server_id: str)
|
||||
|
||||
|
||||
async def filter_cluster_by_subgroup(
|
||||
session: AsyncSession, cluster: list, target_subgroup: str, cluster_id: str
|
||||
session: AsyncSession,
|
||||
cluster: list,
|
||||
target_subgroup: str,
|
||||
cluster_id: str,
|
||||
tariff_id: int | None = None,
|
||||
) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
|
||||
if tariff_id:
|
||||
tariff_id_str = str(tariff_id)
|
||||
q_by_tariff = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
.where(
|
||||
Server.server_name.in_(names),
|
||||
Server.enabled.is_(True),
|
||||
ServerSubgroup.subgroup_title == tariff_id_str,
|
||||
)
|
||||
)
|
||||
allowed_by_tariff = {n for (n,) in q_by_tariff.all()}
|
||||
if allowed_by_tariff:
|
||||
logger.debug(f"Найдены серверы по tariff_id={tariff_id}: {allowed_by_tariff}")
|
||||
return [s for s in cluster if s.get("server_name") in allowed_by_tariff]
|
||||
|
||||
q_allowed = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
@@ -291,11 +321,15 @@ async def filter_cluster_by_subgroup(
|
||||
if allowed:
|
||||
return [s for s in cluster if s.get("server_name") in allowed]
|
||||
|
||||
total_for_subgroup = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == target_subgroup)
|
||||
check_values = [target_subgroup]
|
||||
if tariff_id:
|
||||
check_values.append(str(tariff_id))
|
||||
|
||||
total_bindings = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title.in_(check_values))
|
||||
)
|
||||
if not total_for_subgroup:
|
||||
logger.info(f"Для подгруппы {target_subgroup} нет ни одного сервера. Используем весь кластер {cluster_id}.")
|
||||
if not total_bindings:
|
||||
logger.info(f"Для подгруппы/тарифа нет привязок. Используем весь кластер {cluster_id}.")
|
||||
return cluster
|
||||
|
||||
q_any = await session.execute(
|
||||
@@ -308,8 +342,65 @@ async def filter_cluster_by_subgroup(
|
||||
)
|
||||
any_bound = {n for (n,) in q_any.all()}
|
||||
if any_bound:
|
||||
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}. Продление пропущено.")
|
||||
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}.")
|
||||
return []
|
||||
|
||||
logger.info(f"В кластере {cluster_id} нет привязок подгрупп. Продлеваем по всему кластеру.")
|
||||
logger.info(f"В кластере {cluster_id} нет привязок. Используем весь кластер.")
|
||||
return cluster
|
||||
|
||||
|
||||
async def filter_cluster_by_tariff(session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
|
||||
tariff_id_str = str(tariff_id)
|
||||
|
||||
q_allowed = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
.where(
|
||||
Server.server_name.in_(names),
|
||||
Server.enabled.is_(True),
|
||||
ServerSubgroup.subgroup_title == tariff_id_str,
|
||||
)
|
||||
)
|
||||
allowed = {n for (n,) in q_allowed.all()}
|
||||
if allowed:
|
||||
return [s for s in cluster if s.get("server_name") in allowed]
|
||||
|
||||
total_for_tariff = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == tariff_id_str)
|
||||
)
|
||||
if not total_for_tariff:
|
||||
logger.info(f"Для тарифа {tariff_id} нет привязок серверов. Используем весь кластер {cluster_id}.")
|
||||
return cluster
|
||||
|
||||
q_any = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
.where(
|
||||
Server.server_name.in_(names),
|
||||
Server.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
any_bound = {n for (n,) in q_any.all()}
|
||||
if any_bound:
|
||||
logger.warning(f"Нет серверов под тариф {tariff_id} в кластере {cluster_id}.")
|
||||
return []
|
||||
|
||||
logger.info(f"В кластере {cluster_id} нет привязок тарифов. Используем весь кластер.")
|
||||
return cluster
|
||||
|
||||
|
||||
async def has_legacy_subgroup_bindings(session: AsyncSession, server_ids: list[int]) -> bool:
|
||||
if not server_ids:
|
||||
return False
|
||||
|
||||
result = await session.execute(
|
||||
select(ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(server_ids))
|
||||
)
|
||||
for (title,) in result.all():
|
||||
if title and not title.isdigit():
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import Setting
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str, default: Any = None) -> Any:
|
||||
stmt = select(Setting).where(Setting.key == key)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is None or setting.value is None:
|
||||
return default
|
||||
return setting.value
|
||||
|
||||
|
||||
async def set_setting(
|
||||
session: AsyncSession,
|
||||
key: str,
|
||||
value: Any,
|
||||
description: str | None = None,
|
||||
) -> Setting:
|
||||
stmt = select(Setting).where(Setting.key == key)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(key=key, value=value, description=description)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await session.flush()
|
||||
return setting
|
||||
+33
-6
@@ -3,6 +3,7 @@ from datetime import date, datetime
|
||||
from sqlalchemy import and_, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
|
||||
|
||||
@@ -33,10 +34,28 @@ async def count_active_keys(session: AsyncSession) -> int:
|
||||
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:
|
||||
async def count_active_paid_keys(session: AsyncSession) -> int:
|
||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.expiry_time > current_time_ms)
|
||||
.where(~Key.tariff_id.in_(trial_tariffs_subquery))
|
||||
)
|
||||
|
||||
|
||||
async def count_active_trial_keys(session: AsyncSession) -> int:
|
||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.expiry_time > current_time_ms)
|
||||
.where(Key.tariff_id.in_(trial_tariffs_subquery))
|
||||
)
|
||||
|
||||
|
||||
async def get_tariff_distribution(
|
||||
@@ -80,6 +99,14 @@ async def get_tariff_durations(session: AsyncSession, tariff_ids: list[int]) ->
|
||||
return dict(result.all())
|
||||
|
||||
|
||||
async def get_tariff_subgroups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str | None]:
|
||||
if not tariff_ids:
|
||||
return {}
|
||||
|
||||
result = await session.execute(select(Tariff.id, Tariff.subgroup_title).where(Tariff.id.in_(tariff_ids)))
|
||||
return dict(result.all())
|
||||
|
||||
|
||||
async def count_total_referrals(session: AsyncSession) -> int:
|
||||
return await session.scalar(select(func.count()).select_from(Referral))
|
||||
|
||||
@@ -90,7 +117,7 @@ async def sum_payments_since(session: AsyncSession, since: date) -> float:
|
||||
and_(
|
||||
Payment.created_at >= since,
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -104,7 +131,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
|
||||
Payment.created_at >= start,
|
||||
Payment.created_at < end,
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -116,7 +143,7 @@ async def sum_total_payments(session: AsyncSession) -> float:
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
and_(
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -132,7 +159,7 @@ async def count_hot_leads(session: AsyncSession) -> int:
|
||||
select(Payment.tg_id)
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -73,6 +73,7 @@ async def get_tariffs(
|
||||
return tariffs
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
|
||||
await session.rollback()
|
||||
return []
|
||||
|
||||
|
||||
@@ -83,6 +84,7 @@ async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
|
||||
return dict(tariff.__dict__) if tariff else None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -179,6 +181,7 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int):
|
||||
return False
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
@@ -195,6 +198,7 @@ async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
|
||||
return sort_order
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ async def create_temporary_data(session: AsyncSession, tg_id: int, state: str, d
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
|
||||
|
||||
@@ -2,13 +2,11 @@ from sqlalchemy import and_, func, insert, not_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Payment, TrackingSource, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
|
||||
|
||||
|
||||
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
|
||||
try:
|
||||
stmt = insert(TrackingSource).values(
|
||||
@@ -23,6 +21,7 @@ async def create_tracking_source(session: AsyncSession, name: str, code: str, ty
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
|
||||
@@ -43,7 +42,11 @@ 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")
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
)
|
||||
.correlate(TrackingSource)
|
||||
.scalar_subquery()
|
||||
)
|
||||
@@ -103,7 +106,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
@@ -115,7 +118,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
@@ -146,7 +149,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.subquery()
|
||||
|
||||
+91
-61
@@ -30,7 +30,8 @@ async def add_user(
|
||||
language_code: str = None,
|
||||
is_bot: bool = False,
|
||||
source_code: str = None,
|
||||
):
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
try:
|
||||
stmt = (
|
||||
insert(User)
|
||||
@@ -43,11 +44,17 @@ async def add_user(
|
||||
is_bot=is_bot,
|
||||
source_code=source_code,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[User.tg_id])
|
||||
.on_conflict_do_nothing(index_elements=["tg_id"])
|
||||
.returning(User.tg_id)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
res = await session.execute(stmt)
|
||||
inserted_tg_id = res.scalar_one_or_none()
|
||||
if inserted_tg_id is None:
|
||||
return False
|
||||
if commit:
|
||||
await session.commit()
|
||||
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
@@ -56,6 +63,12 @@ async def add_user(
|
||||
|
||||
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
|
||||
try:
|
||||
if amount < 0:
|
||||
current = await get_balance(session, tg_id)
|
||||
if current + amount < 0:
|
||||
logger.warning(f"[DB] Недостаточно средств: tg_id={tg_id} balance={current} списание={amount}")
|
||||
await session.rollback()
|
||||
raise ValueError(f"Недостаточно средств: баланс {current}, списание {amount}")
|
||||
res = await session.execute(
|
||||
update(User)
|
||||
.where(User.tg_id == tg_id)
|
||||
@@ -82,7 +95,8 @@ async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
|
||||
|
||||
async def get_balance(session: AsyncSession, tg_id: int) -> float:
|
||||
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
|
||||
return round(float(result.scalar_one()), 1)
|
||||
balance = result.scalar_one_or_none()
|
||||
return round(float(balance or 0.0), 1)
|
||||
|
||||
|
||||
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
|
||||
@@ -92,6 +106,7 @@ async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) ->
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def update_trial(session: AsyncSession, tg_id: int, status: int):
|
||||
@@ -102,11 +117,13 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int):
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_trial(session: AsyncSession, tg_id: int) -> int:
|
||||
result = await session.execute(select(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
|
||||
return int(result.scalar_one())
|
||||
trial = result.scalar_one_or_none()
|
||||
return int(trial or 0)
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
@@ -119,58 +136,64 @@ async def upsert_user(
|
||||
is_bot: bool = False,
|
||||
only_if_exists: bool = False,
|
||||
) -> dict | None:
|
||||
"""Создаёт пользователя или обновляет поля профиля."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
returning_cols = list(User.__table__.c)
|
||||
|
||||
if only_if_exists:
|
||||
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
await session.execute(
|
||||
username_value = username if username else User.username
|
||||
first_name_value = first_name if first_name else User.first_name
|
||||
last_name_value = last_name if last_name else User.last_name
|
||||
language_code_value = language_code if language_code else User.language_code
|
||||
|
||||
res = await session.execute(
|
||||
update(User)
|
||||
.where(User.tg_id == tg_id)
|
||||
.values(
|
||||
username=username or user.username,
|
||||
first_name=first_name or user.first_name,
|
||||
last_name=last_name or user.last_name,
|
||||
language_code=language_code or user.language_code,
|
||||
username=username_value,
|
||||
first_name=first_name_value,
|
||||
last_name=last_name_value,
|
||||
language_code=language_code_value,
|
||||
is_bot=is_bot,
|
||||
updated_at=datetime.utcnow(),
|
||||
updated_at=now,
|
||||
)
|
||||
.returning(*returning_cols)
|
||||
)
|
||||
row = res.mappings().one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
await session.commit()
|
||||
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
return dict(result.scalar_one().__dict__)
|
||||
else:
|
||||
res = await session.execute(
|
||||
insert(User)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
is_bot=is_bot,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[User.tg_id],
|
||||
set_={
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
"is_bot": is_bot,
|
||||
"updated_at": datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
.returning(User)
|
||||
return dict(row)
|
||||
|
||||
res = await session.execute(
|
||||
insert(User)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
is_bot=is_bot,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
obj = res.scalar_one()
|
||||
await session.commit()
|
||||
d = obj.__dict__.copy()
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
.on_conflict_do_update(
|
||||
index_elements=[User.tg_id],
|
||||
set_={
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
"is_bot": is_bot,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
.returning(*returning_cols)
|
||||
)
|
||||
row = res.mappings().one()
|
||||
await session.commit()
|
||||
return dict(row)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
@@ -190,7 +213,7 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
|
||||
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 delete_key(session, tg_id, commit=False)
|
||||
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))
|
||||
@@ -208,30 +231,37 @@ async def mark_trial_extended(tg_id: int, session: AsyncSession):
|
||||
|
||||
|
||||
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
|
||||
res = await session.execute(
|
||||
select(func.coalesce(User.trial, 0), func.count(Key.client_id))
|
||||
.select_from(User)
|
||||
.join(Key, Key.tg_id == User.tg_id, isouter=True)
|
||||
.where(User.tg_id == tg_id)
|
||||
.group_by(User.tg_id, User.trial)
|
||||
)
|
||||
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
|
||||
|
||||
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
|
||||
row = res.first()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row[0]), int(row[1])
|
||||
|
||||
|
||||
async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None:
|
||||
async def upsert_source_if_empty(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
source_code: str,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
if not source_code:
|
||||
return
|
||||
return False
|
||||
stmt = (
|
||||
insert(User)
|
||||
.values(tg_id=tg_id, source_code=source_code)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[User.tg_id],
|
||||
index_elements=["tg_id"],
|
||||
set_={"source_code": insert(User).excluded.source_code},
|
||||
where=(User.source_code.is_(None)),
|
||||
)
|
||||
.returning(User.tg_id)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
res = await session.execute(stmt)
|
||||
changed_tg_id = res.scalar_one_or_none()
|
||||
if changed_tg_id is None:
|
||||
return False
|
||||
if commit:
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
bot:
|
||||
container_name: solobot
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /:/host:ro
|
||||
|
||||
+57
-4
@@ -1,17 +1,70 @@
|
||||
import time
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
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
|
||||
|
||||
|
||||
_ADMIN_CACHE: dict[int, tuple[float, bool, bool]] = {}
|
||||
_ADMIN_CACHE_TTL = 60
|
||||
|
||||
|
||||
def _get_cached_admin(user_id: int) -> tuple[bool, bool] | None:
|
||||
now = time.time()
|
||||
entry = _ADMIN_CACHE.get(user_id)
|
||||
if entry and entry[0] > now:
|
||||
return entry[1], entry[2]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached_admin(user_id: int, is_admin: bool, is_superadmin: bool) -> None:
|
||||
_ADMIN_CACHE[user_id] = (time.time() + _ADMIN_CACHE_TTL, is_admin, is_superadmin)
|
||||
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
if not event.from_user:
|
||||
return False
|
||||
|
||||
user_id = event.from_user.id
|
||||
cached = _get_cached_admin(user_id)
|
||||
if cached is not None:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
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:
|
||||
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
|
||||
admin_ids = (ADMIN_ID,) if isinstance(ADMIN_ID, int) else ADMIN_ID
|
||||
is_admin = admin is not None or user_id in admin_ids
|
||||
is_super = admin.role != "moderator" if admin else (user_id in admin_ids)
|
||||
_set_cached_admin(user_id, is_admin, is_super)
|
||||
return is_admin
|
||||
except (Exception,):
|
||||
return False
|
||||
|
||||
|
||||
class IsSuperAdminFilter(BaseFilter):
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
if not event.from_user:
|
||||
return False
|
||||
|
||||
user_id = event.from_user.id
|
||||
cached = _get_cached_admin(user_id)
|
||||
if cached is not None:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
|
||||
if not admin:
|
||||
_set_cached_admin(user_id, False, False)
|
||||
return False
|
||||
is_super = admin.role != "moderator"
|
||||
_set_cached_admin(user_id, True, is_super)
|
||||
return is_super
|
||||
except (Exception,):
|
||||
return False
|
||||
|
||||
@@ -13,6 +13,7 @@ from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
from .start import router as start_router
|
||||
from .tariffs import router as tariff_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
@@ -29,4 +30,5 @@ router.include_routers(
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
tariff_router,
|
||||
)
|
||||
|
||||
@@ -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 .emoji import router as emoji_router
|
||||
from .gifts import router as gifts_router
|
||||
from .management import router as management_router
|
||||
from .module import router as modules_router
|
||||
@@ -14,6 +15,7 @@ from .panel import router as panel_router
|
||||
from .restart import router as restart_router
|
||||
from .sender import router as sender_router
|
||||
from .servers import router as servers_router
|
||||
from .settings import router as settings_router
|
||||
from .stats import router as stats_router
|
||||
from .tariffs import router as tariffs_router
|
||||
from .users import router as users_router
|
||||
@@ -32,9 +34,11 @@ router.include_routers(
|
||||
backups_router,
|
||||
sender_router,
|
||||
coupons_router,
|
||||
emoji_router,
|
||||
restart_router,
|
||||
bans_router,
|
||||
ads_router,
|
||||
tariffs_router,
|
||||
gifts_router,
|
||||
settings_router,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
|
||||
@@ -43,7 +44,7 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
|
||||
if current_page > 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,17 +7,23 @@ from aiogram import F, Router
|
||||
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 aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import delete_user_data
|
||||
from database.models import ManualBan
|
||||
from database.models import BlockedUser, Key, ManualBan
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import (
|
||||
build_bans_kb,
|
||||
build_blocked_users_kb,
|
||||
build_manual_bans_kb,
|
||||
build_shadow_bans_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -32,25 +38,59 @@ async def handle_bans(callback_query: CallbackQuery):
|
||||
text_ = (
|
||||
"🚫 <b>Управление банами</b>\n\n"
|
||||
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
|
||||
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
|
||||
"👻 <b>Теневые баны</b> — пользователи, действия которых игнорируются.\n"
|
||||
"🔒 <b>Ручные баны</b> — пользователи, которых вы забанили через админку.\n\n"
|
||||
"⬇ Выберите нужный раздел:"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_blocked_menu"), IsAdminFilter())
|
||||
async def handle_blocked_users_menu(callback_query: CallbackQuery):
|
||||
text_ = (
|
||||
"📛 <b>Забанившие бота</b>\n\n"
|
||||
"Пользователи, которые заблокировали бота вручную или удалили чат.\n"
|
||||
"⬇ Выберите действие:"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_blocked_users_kb())
|
||||
|
||||
|
||||
def get_shadow_bans_menu_text() -> str:
|
||||
return (
|
||||
"👻 <b>Теневые баны</b>\n\n"
|
||||
"Пользователи, действия которых игнорируются ботом.\n"
|
||||
"Они не получают уведомлений о бане.\n\n"
|
||||
"💡 <b>Можно добавить несколько пользователей за раз:</b>\n"
|
||||
"Отправьте список Telegram ID (один на строке).\n"
|
||||
"Пример:\n<code>123456789\n987654321\n555666777</code>\n\n"
|
||||
"⬇ Выберите действие:"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
|
||||
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
|
||||
async def handle_manual_bans_menu(callback_query: CallbackQuery):
|
||||
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
|
||||
|
||||
|
||||
@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")
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||
banned_users = result.all()
|
||||
result = await session.execute(select(BlockedUser.tg_id))
|
||||
banned_users = result.scalars().all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id"])
|
||||
|
||||
for user in banned_users:
|
||||
writer.writerow([user.tg_id])
|
||||
for tg_id in banned_users:
|
||||
writer.writerow([tg_id])
|
||||
|
||||
csv_output.seek(0)
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
|
||||
@@ -68,11 +108,11 @@ 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):
|
||||
kb = build_admin_back_kb("bans")
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||
blocked_users = result.all()
|
||||
blocked_ids = [user.tg_id for user in blocked_users]
|
||||
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
|
||||
result = await session.execute(stmt)
|
||||
blocked_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not blocked_ids:
|
||||
await callback_query.message.answer(
|
||||
@@ -84,12 +124,6 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Asyn
|
||||
for tg_id in blocked_ids:
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
await session.execute(
|
||||
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
|
||||
{"blocked_ids": blocked_ids},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
|
||||
reply_markup=kb,
|
||||
@@ -101,60 +135,188 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Asyn
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "shadow_bans_export"), IsAdminFilter())
|
||||
async def handle_shadow_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_shadow_bans_kb()
|
||||
try:
|
||||
result = await session.execute(text("SELECT tg_id, banned_at, reason, until FROM manual_bans"))
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
|
||||
ManualBan.reason == "shadow"
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "banned_at", "reason", "until"])
|
||||
writer.writerow(["tg_id", "banned_at", "banned_by", "until"])
|
||||
|
||||
for user in rows:
|
||||
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
|
||||
writer.writerow([user.tg_id, user.banned_at, user.banned_by, user.until])
|
||||
|
||||
csv_output.seek(0)
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="shadow_bans.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=document,
|
||||
caption="📥 Экспорт теневых банов (CSV)",
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при экспорте: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
|
||||
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
|
||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "banned_at", "reason", "until", "banned_by"])
|
||||
|
||||
for user in rows:
|
||||
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until, user.banned_by])
|
||||
|
||||
csv_output.seek(0)
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=document,
|
||||
caption="📥 Экспорт вручную забаненных пользователей",
|
||||
caption="📥 Экспорт вручную забаненных пользователей (CSV)",
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при экспорте: {e}",
|
||||
reply_markup=build_admin_back_kb("bans"),
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@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_clear_blocked"), IsAdminFilter())
|
||||
async def handle_clear_blocked_users(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
await session.execute(delete(ManualBan))
|
||||
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет забанивших пользователей для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(BlockedUser))
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
"🗑️ Вручную забаненные пользователи удалены.",
|
||||
reply_markup=build_bans_kb(),
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info("[BANS] Очищены записи из manual_bans")
|
||||
logger.info(f"[BANS] Очищено {total_count} записей из blocked_users")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
|
||||
await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.")
|
||||
logger.error(f"[BANS] Ошибка при очистке blocked_users: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке забанивших пользователей: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_shadow"), IsAdminFilter())
|
||||
async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_shadow_bans_kb()
|
||||
try:
|
||||
count_result = await session.execute(
|
||||
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет теневых банов для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info(f"[BANS] Очищено {total_count} записей теневых банов из manual_bans")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке теневых банов: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке теневых банов: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_manual"), IsAdminFilter())
|
||||
async def handle_clear_manual_bans(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
count_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ManualBan)
|
||||
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет ручных банов для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info(f"[BANS] Очищено {total_count} записей ручных банов из manual_bans")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке ручных банов: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке ручных банов: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="❌ Отмена",
|
||||
callback_data=AdminPanelCallback(action="bans_cancel_preemptive").pack(),
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
|
||||
"Пример:\n<code>123456789\n987654321</code>",
|
||||
reply_markup=build_admin_back_kb("bans"),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
|
||||
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@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()
|
||||
@@ -197,5 +359,8 @@ async def handle_preemptive_ids_input(message: Message, state: FSMContext, sessi
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
|
||||
await message.answer(
|
||||
f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.",
|
||||
reply_markup=build_shadow_bans_kb(),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
def build_bans_kb():
|
||||
@@ -8,27 +9,87 @@ def build_bans_kb():
|
||||
|
||||
builder.button(
|
||||
text="📛 Забанившие бота",
|
||||
callback_data=AdminPanelCallback(action="bans_blocked_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="👻 Теневые баны",
|
||||
callback_data=AdminPanelCallback(action="bans_shadow_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔒 Ручные баны",
|
||||
callback_data=AdminPanelCallback(action="bans_manual_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_blocked_users_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
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="🗑️ Очистить забаненных",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
|
||||
text="🗑️ Очистить забанивших",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_blocked").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_shadow_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="shadow_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="➕ Забанить заранее",
|
||||
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить теневые баны",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_shadow").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manual_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить ручные баны",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_manual").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
__all__ = ("router",)
|
||||
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa
|
||||
from .base import AdminClusterStates, router
|
||||
|
||||
from .clusters_handler import router
|
||||
|
||||
__all__ = ["router", "AdminClusterStates"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from aiogram import Router
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminClusterStates(StatesGroup):
|
||||
waiting_for_cluster_name = State()
|
||||
waiting_for_api_url = State()
|
||||
waiting_for_inbound_id = State()
|
||||
waiting_for_server_name = State()
|
||||
waiting_for_subscription_url = State()
|
||||
waiting_for_days_input = State()
|
||||
waiting_for_new_cluster_name = State()
|
||||
waiting_for_new_server_name = State()
|
||||
waiting_for_server_transfer = State()
|
||||
waiting_for_cluster_transfer = State()
|
||||
@@ -0,0 +1,452 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_servers, update_key_expiry
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .base import AdminClusterStates, router
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_cluster_management_kb,
|
||||
build_manage_cluster_kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.tariff_group.isnot(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
tariff_group = row[0] if row else "—"
|
||||
|
||||
result = await session.execute(select(Server.server_name).where(Server.cluster_name == cluster_name))
|
||||
server_names = [row[0] for row in result.all()]
|
||||
result = await session.execute(
|
||||
select(func.count(func.distinct(Key.tg_id))).where(
|
||||
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
|
||||
)
|
||||
)
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
result = await session.execute(
|
||||
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
|
||||
)
|
||||
subscription_count = result.scalar() or 0
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
|
||||
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
|
||||
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
|
||||
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
|
||||
allowed = set(ALLOWED_GROUP_CODES)
|
||||
lines = []
|
||||
for s in cluster_servers:
|
||||
tids = s.get("tariff_ids") or []
|
||||
subs = s.get("tariff_subgroups") or []
|
||||
if tids:
|
||||
subs_str = f"{len(tids)} тариф(ов)"
|
||||
elif subs:
|
||||
subs_str = ", ".join(sorted(subs))
|
||||
else:
|
||||
subs_str = "—"
|
||||
|
||||
grps = s.get("special_groups") or []
|
||||
grps = [g for g in grps if g in allowed]
|
||||
grps_str = ", ".join(sorted(grps)) if grps else "—"
|
||||
|
||||
lines.append(f"• {s.get('server_name', '?')} — {subs_str} | {grps_str}")
|
||||
|
||||
details = "\n".join(lines) if lines else "нет серверов"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=(
|
||||
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n"
|
||||
f"<blockquote>{details}</blockquote>"
|
||||
),
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||
async def handle_add_time(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
await state.set_state(AdminClusterStates.waiting_for_days_input)
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
|
||||
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
if days <= 0:
|
||||
raise ValueError
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
add_ms = days * 86400 * 1000
|
||||
|
||||
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
|
||||
|
||||
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
|
||||
server_rows = await session.execute(server_stmt)
|
||||
server_names = [row[0] for row in server_rows.all()]
|
||||
server_names.append(cluster_name)
|
||||
|
||||
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
|
||||
keys = result.scalars().all()
|
||||
|
||||
if not keys:
|
||||
await message.answer("❌ Нет подписок в этом кластере или сервере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await message.answer("❌ Не найдены серверы в кластере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
is_full_remnawave = all(str(s.get("panel_type", "")).lower() == "remnawave" for s in cluster_servers)
|
||||
|
||||
if is_full_remnawave:
|
||||
uuids = [key.client_id for key in keys if key.client_id]
|
||||
|
||||
if not uuids:
|
||||
await message.answer("❌ Нет валидных подписок для продления.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
api_url = cluster_servers[0].get("api_url", "")
|
||||
if not api_url:
|
||||
await message.answer("❌ Не найден URL панели для кластера.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
remna = RemnawaveAPI(api_url)
|
||||
|
||||
try:
|
||||
result_bulk = await remna.bulk_extend_expiration_date(uuids, days)
|
||||
finally:
|
||||
await remna.aclose()
|
||||
|
||||
if result_bulk is None:
|
||||
await message.answer("❌ Ошибка при обращении к API панели.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
affected = result_bulk.get("affectedRows", 0)
|
||||
logger.info(f"[Cluster Extend] Bulk API: продлено {affected} подписок на {days} дней")
|
||||
|
||||
for key in keys:
|
||||
new_expiry = key.expiry_time + add_ms
|
||||
await update_key_expiry(session, key.client_id, new_expiry)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> для <b>{affected}</b> пользователей в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
else:
|
||||
for key in keys:
|
||||
new_expiry = key.expiry_time + add_ms
|
||||
|
||||
traffic_limit = 0
|
||||
device_limit = 0
|
||||
key_subgroup = None
|
||||
if key.tariff_id:
|
||||
tariff_result = await session.execute(
|
||||
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
|
||||
Tariff.id == key.tariff_id,
|
||||
Tariff.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
tariff = tariff_result.first()
|
||||
if tariff:
|
||||
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
|
||||
device_limit = int(tariff[1]) if tariff[1] is not None else 0
|
||||
key_subgroup = tariff[2]
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email=key.email,
|
||||
client_id=key.client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=traffic_limit,
|
||||
session=session,
|
||||
hwid_device_limit=device_limit,
|
||||
reset_traffic=False,
|
||||
target_subgroup=key_subgroup,
|
||||
old_subgroup=key_subgroup,
|
||||
plan=key.tariff_id,
|
||||
)
|
||||
await update_key_expiry(session, key.client_id, new_expiry)
|
||||
|
||||
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней.")
|
||||
except Exception as e:
|
||||
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
|
||||
await message.answer("❌ Произошла ошибка при продлении времени.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_cluster(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
await state.update_data(old_cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_cluster</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
|
||||
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_cluster_name = message.text.strip()
|
||||
if len(new_cluster_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_cluster_name = user_data.get("old_cluster_name")
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
|
||||
)
|
||||
existing_cluster = result.scalar()
|
||||
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
keys_count_result = await session.execute(
|
||||
select(func.count()).select_from(Key).where(Key.server_id == old_cluster_name)
|
||||
)
|
||||
keys_count = keys_count_result.scalar()
|
||||
|
||||
await session.execute(
|
||||
update(Server).where(Server.cluster_name == old_cluster_name).values(cluster_name=new_cluster_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(
|
||||
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_server(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
old_server_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
if server["server_name"] == old_server_name:
|
||||
cluster_name = c_name
|
||||
break
|
||||
if cluster_name:
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным в пределах кластера.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_server</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
|
||||
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_server_name = message.text.strip()
|
||||
if len(new_server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_server_name = user_data.get("old_server_name")
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == new_server_name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
existing_server = result.scalar()
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
text=(
|
||||
f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. "
|
||||
f"Введите другое имя."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
|
||||
keys_count = result.scalar()
|
||||
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
.values(server_name=new_server_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
text=(
|
||||
f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' "
|
||||
f"в кластере '{cluster_name}'!"
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
@@ -0,0 +1,820 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.types import CallbackQuery
|
||||
from py3xui import AsyncApi
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import get_servers
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
)
|
||||
from handlers.keys.operations.aggregated_links import make_aggregated_link
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from utils.backup import create_backup_and_send_to_admins
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .base import router
|
||||
from .keyboard import AdminClusterCallback, build_availability_kb, build_sync_cluster_kb
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
|
||||
async def handle_cluster_availability(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
)
|
||||
)
|
||||
|
||||
total_online_users = 0
|
||||
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
|
||||
|
||||
for server in cluster_servers:
|
||||
server_name = server["server_name"]
|
||||
panel_type = server.get("panel_type", "3x-ui").lower()
|
||||
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
|
||||
|
||||
try:
|
||||
if panel_type == "3x-ui":
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=None,
|
||||
)
|
||||
await xui.login()
|
||||
inbound_id = int(server["inbound_id"])
|
||||
online_clients = await xui.client.online()
|
||||
online_inbound_users = 0
|
||||
|
||||
for client_email in online_clients:
|
||||
client = await xui.client.get_by_email(client_email)
|
||||
if client and client.inbound_id == inbound_id:
|
||||
online_inbound_users += 1
|
||||
|
||||
total_online_users += online_inbound_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
|
||||
|
||||
elif panel_type == "remnawave":
|
||||
server_inbound_id = server.get("inbound_id")
|
||||
if not server_inbound_id:
|
||||
raise Exception("Не указан inbound_id сервера")
|
||||
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
nodes_data = await remna.get_all_nodes_with_online(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
inbound_id=server_inbound_id,
|
||||
)
|
||||
|
||||
if nodes_data.get("error"):
|
||||
raise Exception(nodes_data["error"])
|
||||
|
||||
online_remna_users = nodes_data["total_online"]
|
||||
total_online_users += online_remna_users
|
||||
|
||||
nodes_info = nodes_data["nodes"]
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
seen = set()
|
||||
for node_info in nodes_info:
|
||||
node_name = node_info.get("name", "Unknown")
|
||||
if node_name in seen:
|
||||
continue
|
||||
seen.add(node_name)
|
||||
|
||||
country_code = node_info.get("country_code", "Unknown")
|
||||
online_users = node_info.get("online_users", 0)
|
||||
|
||||
flag = (
|
||||
"".join(chr(ord(c) + 127397) for c in country_code.upper())
|
||||
if country_code != "Unknown" and len(country_code) == 2
|
||||
else country_code
|
||||
)
|
||||
result_text += f" ↳ {flag} ({node_name}): {online_users} онлайн\n"
|
||||
|
||||
except Exception as e:
|
||||
error_text = str(e) or "Сервер недоступен"
|
||||
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
|
||||
|
||||
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||
await callback_query.message.edit_text(
|
||||
text=result_text,
|
||||
reply_markup=build_availability_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
|
||||
async def handle_clusters_backup(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
if server.get("panel_type") == "remnawave":
|
||||
continue
|
||||
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
text = (
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
|
||||
async def handle_sync(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
|
||||
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
|
||||
async def handle_sync_server(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
server_name = callback_data.data
|
||||
|
||||
try:
|
||||
server_result = await session.execute(
|
||||
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
|
||||
)
|
||||
cluster_name = server_result.scalar()
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Сервер {server_name} не найден.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if use_country_selection:
|
||||
stmt = (
|
||||
select(
|
||||
Server.api_url,
|
||||
Server.inbound_id,
|
||||
Server.server_name,
|
||||
Server.panel_type,
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.tariff_id,
|
||||
Key.remnawave_link,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
)
|
||||
.join(Key, Server.server_name == Key.server_id)
|
||||
.where(Server.server_name == server_name)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(
|
||||
Server.api_url,
|
||||
Server.inbound_id,
|
||||
Server.server_name,
|
||||
Server.panel_type,
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.tariff_id,
|
||||
Key.remnawave_link,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
)
|
||||
.join(Key, Server.cluster_name == Key.server_id)
|
||||
.where(Server.server_name == server_name)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
all_servers = await get_servers(session)
|
||||
cluster_servers = all_servers.get(cluster_name, [])
|
||||
|
||||
server_info = None
|
||||
for s in cluster_servers:
|
||||
if s.get("server_name") == server_name:
|
||||
server_info = s
|
||||
break
|
||||
|
||||
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
|
||||
tariffs_cache = {}
|
||||
if tariff_ids:
|
||||
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
tariffs_list = tariffs_result.scalars().all()
|
||||
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
if key["panel_type"] == "remnawave":
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
if tariff and server_info:
|
||||
subgroup = tariff.get("subgroup_title")
|
||||
tid = key["tariff_id"]
|
||||
has_new_binding = tid and tid in (server_info.get("tariff_ids") or [])
|
||||
has_old_binding = subgroup and subgroup in (server_info.get("tariff_subgroups") or [])
|
||||
has_any_binding = bool(server_info.get("tariff_ids") or server_info.get("tariff_subgroups"))
|
||||
|
||||
if has_any_binding and subgroup and not has_new_binding and not has_old_binding:
|
||||
continue
|
||||
|
||||
if tariff.get("group_code") and tariff.get("group_code").lower() in ALLOWED_GROUP_CODES:
|
||||
if tariff.get("group_code").lower() not in server_info.get("special_groups", []):
|
||||
continue
|
||||
|
||||
expire_iso = (
|
||||
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
remna = RemnawaveAPI(key["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
|
||||
continue
|
||||
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
success = await remna.update_user(
|
||||
uuid=key["client_id"],
|
||||
expire_at=expire_iso,
|
||||
telegram_id=key["tg_id"],
|
||||
email=f"{key['email']}@fake.local",
|
||||
active_user_inbounds=[key["inbound_id"]],
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
hwid_device_limit=hwid_limit,
|
||||
)
|
||||
|
||||
if success:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(key["email"])
|
||||
if sub:
|
||||
new_remnawave_link = sub.get("subscriptionUrl")
|
||||
|
||||
if new_remnawave_link:
|
||||
key_value = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=cluster_servers,
|
||||
cluster_id=cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
tg_id=key["tg_id"],
|
||||
remna_link_override=None,
|
||||
plan=tariff,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||
.values(remnawave_link=new_remnawave_link, key=key_value)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
|
||||
|
||||
if not success:
|
||||
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
|
||||
|
||||
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_id=server_name,
|
||||
tg_id=key["tg_id"],
|
||||
client_id=key["client_id"],
|
||||
email=key["email"],
|
||||
expiry_timestamp=key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=hwid_limit,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
else:
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": key["api_url"],
|
||||
"inbound_id": key["inbound_id"],
|
||||
"server_name": key["server_name"],
|
||||
},
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
semaphore,
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
|
||||
async def handle_sync_cluster(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if use_country_selection:
|
||||
server_names = [s.get("server_name") for s in cluster_servers if s.get("server_name")]
|
||||
if not server_names:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ В кластере {cluster_name} нет серверов.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
result = await session.execute(
|
||||
select(
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
Key.server_id,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
).where(Key.server_id.in_(server_names), Key.is_frozen.is_(False))
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
Key.server_id,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
|
||||
)
|
||||
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
only_remnawave = all(s.get("panel_type") == "remnawave" for s in cluster_servers)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
|
||||
tariffs_cache = {}
|
||||
if tariff_ids:
|
||||
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
tariffs_list = tariffs_result.scalars().all()
|
||||
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
||||
|
||||
if only_remnawave:
|
||||
batch_size = 50
|
||||
total_keys = len(keys_to_sync)
|
||||
processed_count = 0
|
||||
|
||||
for batch_start in range(0, total_keys, batch_size):
|
||||
batch = keys_to_sync[batch_start : batch_start + batch_size]
|
||||
batch_end = batch_start + len(batch)
|
||||
logger.info(f"[Sync] Обработка батча {batch_start}-{batch_end} из {total_keys}")
|
||||
|
||||
async def update_remnawave_api(key):
|
||||
try:
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
subgroup_title = None
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
else:
|
||||
traffic_limit_bytes = 0
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
subgroup_title = tariff.get("subgroup_title")
|
||||
|
||||
expire_iso = (
|
||||
datetime.utcfromtimestamp(key["expiry_time"] / 1000)
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
)
|
||||
|
||||
if use_country_selection:
|
||||
user_server = None
|
||||
for s in cluster_servers:
|
||||
if s.get("server_name") == key["server_id"]:
|
||||
user_server = s
|
||||
break
|
||||
|
||||
if not user_server:
|
||||
return {"key": key, "success": False, "error": "Server not found"}
|
||||
|
||||
remna = RemnawaveAPI(user_server["api_url"])
|
||||
inbound_ids = [user_server["inbound_id"]] if user_server.get("inbound_id") else []
|
||||
else:
|
||||
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
|
||||
|
||||
filtered_servers = cluster_servers
|
||||
if subgroup_title or (tariff and tariff.get("id")):
|
||||
tid = tariff.get("id") if tariff else None
|
||||
filtered_servers = [
|
||||
s
|
||||
for s in cluster_servers
|
||||
if (tid and tid in (s.get("tariff_ids") or []))
|
||||
or (subgroup_title and subgroup_title in (s.get("tariff_subgroups") or []))
|
||||
]
|
||||
if not filtered_servers:
|
||||
filtered_servers = cluster_servers
|
||||
|
||||
if tariff and tariff.get("group_code"):
|
||||
group_code = tariff.get("group_code").lower()
|
||||
if group_code in ALLOWED_GROUP_CODES:
|
||||
special_filtered = [
|
||||
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
|
||||
]
|
||||
if special_filtered:
|
||||
filtered_servers = special_filtered
|
||||
|
||||
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
|
||||
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
return {"key": key, "success": False, "error": "Login failed"}
|
||||
|
||||
success = await remna.update_user(
|
||||
uuid=key["client_id"],
|
||||
expire_at=expire_iso,
|
||||
telegram_id=key["tg_id"],
|
||||
email=f"{key['email']}@fake.local",
|
||||
active_user_inbounds=inbound_ids,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
hwid_device_limit=hwid_limit,
|
||||
)
|
||||
|
||||
if success:
|
||||
sub = await remna.get_subscription_by_username(key["email"])
|
||||
new_link = sub.get("subscriptionUrl") if sub else None
|
||||
return {
|
||||
"key": key,
|
||||
"success": True,
|
||||
"new_link": new_link,
|
||||
"tariff": tariff,
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"hwid_limit": hwid_limit,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"key": key,
|
||||
"success": False,
|
||||
"needs_recreate": True,
|
||||
"tariff": tariff,
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"hwid_limit": hwid_limit,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка API для {key.get('email')}: {e}")
|
||||
return {"key": key, "success": False, "error": str(e)}
|
||||
|
||||
tasks = [update_remnawave_api(key) for key in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
bulk_updates = []
|
||||
recreate_tasks = []
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"[Sync] Exception в батче: {result}")
|
||||
continue
|
||||
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
|
||||
key = result.get("key")
|
||||
if not key:
|
||||
continue
|
||||
|
||||
try:
|
||||
if result.get("success") and result.get("new_link"):
|
||||
new_link = result["new_link"]
|
||||
tariff = result.get("tariff")
|
||||
|
||||
key_value = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=cluster_servers,
|
||||
cluster_id=cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
tg_id=key["tg_id"],
|
||||
remna_link_override=None,
|
||||
plan=tariff,
|
||||
)
|
||||
|
||||
bulk_updates.append({
|
||||
"client_id": key["client_id"],
|
||||
"remnawave_link": new_link,
|
||||
"key": key_value,
|
||||
})
|
||||
|
||||
elif result.get("needs_recreate"):
|
||||
recreate_tasks.append((key, result))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка подготовки для {key.get('email')}: {e}")
|
||||
|
||||
if bulk_updates:
|
||||
try:
|
||||
await session.run_sync(
|
||||
lambda sync_session: sync_session.bulk_update_mappings(Key, bulk_updates)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[Sync] Bulk: обновлено {len(bulk_updates)} ключей")
|
||||
except Exception as bulk_error:
|
||||
logger.warning(f"[Sync] Bulk упал, fallback: {bulk_error}")
|
||||
await session.rollback()
|
||||
|
||||
for upd in bulk_updates:
|
||||
try:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.client_id == upd["client_id"])
|
||||
.values(remnawave_link=upd["remnawave_link"], key=upd["key"])
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Fallback ошибка {upd['client_id']}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
for key, result in recreate_tasks:
|
||||
try:
|
||||
logger.warning(f"[Sync] Пересоздание {key['email']}")
|
||||
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"])
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
|
||||
await create_key_on_cluster(
|
||||
cluster_id_for_recreate,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=result.get("hwid_limit"),
|
||||
traffic_limit_bytes=result.get("traffic_limit_bytes"),
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Пересоздание ошибка {key.get('email')}: {e}")
|
||||
|
||||
processed_count = batch_end
|
||||
progress_percent = int((processed_count / total_keys) * 100)
|
||||
progress_bar = "█" * (progress_percent // 5) + "░" * (20 - progress_percent // 5)
|
||||
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n"
|
||||
f"🔑 Количество ключей: <b>{total_keys}</b>\n\n"
|
||||
f"Обработано: <b>{processed_count}/{total_keys}</b>\n"
|
||||
f"<code>{progress_bar}</code>"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
else:
|
||||
traffic_limit_bytes = 0
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
tariff.get("subgroup_title")
|
||||
elif key["tariff_id"]:
|
||||
logger.warning(
|
||||
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']} — "
|
||||
f"обновим без лимитов"
|
||||
)
|
||||
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
|
||||
|
||||
await session.execute(
|
||||
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||
)
|
||||
|
||||
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
|
||||
await create_key_on_cluster(
|
||||
cluster_id_for_recreate,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=hwid_limit,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"✅ <b>Синхронизация завершена</b>\n\n"
|
||||
f"📊 Кластер: <b>{cluster_name}</b>\n"
|
||||
f"🔑 Обработано ключей: <b>{len(keys_to_sync)}</b>"
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка синхронизации кластера {cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
@@ -0,0 +1,628 @@
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import and_, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_servers
|
||||
from database.models import Server, ServerSpecialgroup, ServerSubgroup, Tariff
|
||||
from database.servers import has_legacy_subgroup_bindings
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
from logger import logger
|
||||
|
||||
from .base import router
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
build_attach_tariff_kb,
|
||||
build_legacy_reset_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_select_group_servers_kb,
|
||||
build_select_subgroup_servers_kb,
|
||||
build_tariff_group_selection_for_servers_kb,
|
||||
build_tariff_group_selection_kb,
|
||||
build_tariff_selection_kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
|
||||
async def show_tariff_group_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
result = await session.execute(
|
||||
select(Tariff.id, Tariff.group_code).where(Tariff.group_code.isnot(None)).distinct(Tariff.group_code)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
groups = [(r["id"], r["group_code"]) for r in rows]
|
||||
|
||||
if not groups:
|
||||
await callback.message.edit_text("❌ Нет доступных тарифных групп.")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>💸 Выберите тарифную группу для кластера <code>{cluster_name}</code>:</b>",
|
||||
reply_markup=build_tariff_group_selection_kb(cluster_name, groups),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
|
||||
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||
try:
|
||||
cluster_name, group_id = callback_data.data.split("|", 1)
|
||||
group_id = int(group_id)
|
||||
|
||||
result = await session.execute(select(Tariff.group_code).where(Tariff.id == group_id))
|
||||
row = result.mappings().first()
|
||||
|
||||
if not row:
|
||||
await callback.message.edit_text("❌ Тарифная группа не найдена.")
|
||||
return
|
||||
|
||||
group_code = row["group_code"]
|
||||
|
||||
await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
|
||||
await session.commit()
|
||||
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении тарифной группы: {e}")
|
||||
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "set_subgroup"), IsAdminFilter())
|
||||
async def show_servers_for_tariffs(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
server_ids = [s.get("server_id") for s in cluster_servers if s.get("server_id")]
|
||||
if server_ids and await has_legacy_subgroup_bindings(session, server_ids):
|
||||
await callback.message.edit_text(
|
||||
f"<b>⚠️ Обнаружены привязки старого формата</b>\n\n"
|
||||
f"Кластер <code>{cluster_name}</code> содержит привязки по названиям подгрупп.\n"
|
||||
f"Для использования новой системы необходимо сбросить текущие привязки.\n\n"
|
||||
f"<i>После сброса вы сможете привязать тарифы по ID.</i>",
|
||||
reply_markup=build_legacy_reset_kb(cluster_name),
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_subgroup"), IsAdminFilter())
|
||||
async def toggle_server_for_tariffs(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||
i = int(idx_str)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
if i < 0 or i >= len(names):
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
server_name = names[i]
|
||||
key = f"subgrp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if server_name in selected:
|
||||
selected.remove(server_name)
|
||||
else:
|
||||
selected.add(server_name)
|
||||
await state.update_data({key: list(selected)})
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_subgroup_selection"), IsAdminFilter())
|
||||
async def reset_tariff_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
await state.update_data({
|
||||
f"subgrp_sel:{cluster_name}": [],
|
||||
f"tariff_sel:{cluster_name}": [],
|
||||
})
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_subgroup"), IsAdminFilter())
|
||||
async def choose_tariffs(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
key = f"subgrp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected_servers = set(data.get(key, []))
|
||||
if not selected_servers:
|
||||
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
|
||||
return
|
||||
|
||||
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
|
||||
group_codes = [r[0] for r in res.fetchall() if r[0]]
|
||||
if not group_codes:
|
||||
await callback.answer("Сначала установите тарифную группу для этого кластера", show_alert=True)
|
||||
return
|
||||
|
||||
group_code = group_codes[0]
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ Для этой группы нет доступных тарифов.")
|
||||
return
|
||||
|
||||
servers_q = await session.execute(select(Server.id).where(Server.server_name.in_(selected_servers)))
|
||||
server_ids = [row[0] for row in servers_q.fetchall()]
|
||||
|
||||
current_bindings_q = await session.execute(
|
||||
select(ServerSubgroup.subgroup_title)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
|
||||
)
|
||||
current_tariff_ids = {int(row[0]) for row in current_bindings_q.fetchall()}
|
||||
|
||||
await state.update_data({f"tariff_sel:{cluster_name}": list(current_tariff_ids)})
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, current_tariff_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_tariff"), IsAdminFilter())
|
||||
async def toggle_tariff_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name, tariff_id_str = callback_data.data.split("|", 1)
|
||||
tariff_id = int(tariff_id_str)
|
||||
|
||||
key = f"tariff_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected_tariffs = set(data.get(key, []))
|
||||
|
||||
if tariff_id in selected_tariffs:
|
||||
selected_tariffs.remove(tariff_id)
|
||||
else:
|
||||
selected_tariffs.add(tariff_id)
|
||||
|
||||
await state.update_data({key: list(selected_tariffs)})
|
||||
|
||||
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
|
||||
group_codes = [r[0] for r in res.fetchall() if r[0]]
|
||||
if not group_codes:
|
||||
return
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_codes[0], Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, selected_tariffs),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariffs"), IsAdminFilter())
|
||||
async def apply_tariffs(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
cluster_name = callback_data.data
|
||||
data = await state.get_data()
|
||||
|
||||
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
selected_tariffs = set(data.get(f"tariff_sel:{cluster_name}", []))
|
||||
|
||||
if not selected_servers:
|
||||
await callback.answer("Не выбраны серверы", show_alert=True)
|
||||
return
|
||||
|
||||
servers_q = await session.execute(
|
||||
select(Server.id, Server.server_name, Server.tariff_group).where(Server.server_name.in_(selected_servers))
|
||||
)
|
||||
servers_data = servers_q.fetchall()
|
||||
server_ids = [row[0] for row in servers_data]
|
||||
group_code = servers_data[0][2] if servers_data else "standard"
|
||||
|
||||
if not server_ids:
|
||||
await callback.answer("Серверы не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
selected_tariff_strs = {str(tid) for tid in selected_tariffs}
|
||||
|
||||
await session.execute(
|
||||
delete(ServerSubgroup)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
|
||||
.where(ServerSubgroup.subgroup_title.notin_(selected_tariff_strs))
|
||||
)
|
||||
|
||||
for tariff_id in selected_tariffs:
|
||||
tariff_id_str = str(tariff_id)
|
||||
|
||||
existing_q = await session.execute(
|
||||
select(ServerSubgroup.server_id)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
.where(ServerSubgroup.subgroup_title == tariff_id_str)
|
||||
)
|
||||
already = {r[0] for r in existing_q.fetchall()}
|
||||
to_insert = [sid for sid in server_ids if sid not in already]
|
||||
|
||||
if to_insert:
|
||||
session.add_all([
|
||||
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=tariff_id_str)
|
||||
for sid in to_insert
|
||||
])
|
||||
|
||||
await session.commit()
|
||||
|
||||
await state.update_data({
|
||||
f"subgrp_sel:{cluster_name}": [],
|
||||
f"tariff_sel:{cluster_name}": [],
|
||||
})
|
||||
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
all_tariff_ids = set()
|
||||
for s in cluster_servers:
|
||||
all_tariff_ids.update(s.get("tariff_ids") or [])
|
||||
|
||||
tariffs_cache = {}
|
||||
if all_tariff_ids:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
|
||||
for t in result.scalars().all():
|
||||
tariffs_cache[t.id] = {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"subgroup_title": t.subgroup_title,
|
||||
"group_code": t.group_code,
|
||||
}
|
||||
|
||||
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении тарифов: {e}")
|
||||
await callback.message.edit_text("❌ Произошла ошибка при назначении тарифов.")
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_subgroups"), IsAdminFilter())
|
||||
async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||
try:
|
||||
cluster_name = callback_data.data
|
||||
|
||||
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
|
||||
server_ids = [row[0] for row in res.fetchall()]
|
||||
if not server_ids:
|
||||
await callback.answer("В кластере нет серверов", show_alert=True)
|
||||
return
|
||||
|
||||
await session.execute(delete(ServerSubgroup).where(ServerSubgroup.server_id.in_(server_ids)))
|
||||
await session.commit()
|
||||
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Все подгруппы тарифов сброшены для кластера <b>{cluster_name}</b>.",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
|
||||
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
|
||||
|
||||
|
||||
def render_attach_tariff_menu_text(
|
||||
cluster_name: str, cluster_servers: list[dict], tariffs_cache: dict[int, dict] | None = None
|
||||
) -> str:
|
||||
tariff_map: dict[int, list[str]] = {}
|
||||
legacy_map: dict[str, list[str]] = {}
|
||||
|
||||
for s in cluster_servers:
|
||||
server_name = s["server_name"]
|
||||
|
||||
for tid in s.get("tariff_ids") or []:
|
||||
tariff_map.setdefault(tid, []).append(server_name)
|
||||
|
||||
for sg in s.get("tariff_subgroups") or []:
|
||||
legacy_map.setdefault(sg, []).append(server_name)
|
||||
|
||||
allowed = tuple(ALLOWED_GROUP_CODES)
|
||||
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
|
||||
for s in cluster_servers:
|
||||
for g in s.get("special_groups") or []:
|
||||
if g in spec_map:
|
||||
spec_map[g].append(s["server_name"])
|
||||
|
||||
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
|
||||
|
||||
lines.append("\n<b>📋 Тарифы:</b>")
|
||||
if tariff_map and tariffs_cache:
|
||||
grouped: dict[str | None, list[tuple[int, str, list[str]]]] = {}
|
||||
for tid, servers in tariff_map.items():
|
||||
tariff = tariffs_cache.get(tid, {})
|
||||
subgroup = tariff.get("subgroup_title")
|
||||
name = tariff.get("name", f"ID:{tid}")
|
||||
grouped.setdefault(subgroup, []).append((tid, name, servers))
|
||||
|
||||
tariff_lines = []
|
||||
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
|
||||
|
||||
for subgroup in subgroups_sorted:
|
||||
tariffs_list = grouped[subgroup]
|
||||
if subgroup:
|
||||
tariff_lines.append(f"<b>{subgroup}</b>")
|
||||
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
|
||||
servers_str = ", ".join(sorted(set(servers)))
|
||||
tariff_lines.append(f" └ {name}: {servers_str}")
|
||||
else:
|
||||
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
|
||||
servers_str = ", ".join(sorted(set(servers)))
|
||||
tariff_lines.append(f"• {name}: {servers_str}")
|
||||
|
||||
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
|
||||
elif tariff_map:
|
||||
tariff_lines = []
|
||||
for tid, servers in sorted(tariff_map.items()):
|
||||
servers_str = ", ".join(sorted(set(servers)))
|
||||
tariff_lines.append(f"• ID:{tid}: {servers_str}")
|
||||
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
|
||||
else:
|
||||
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||
|
||||
if legacy_map:
|
||||
lines.append("\n<b>⚠️ Старые привязки (по названию):</b>")
|
||||
legacy_lines = []
|
||||
for k in sorted(legacy_map):
|
||||
servers_list = ", ".join(sorted(set(legacy_map[k])))
|
||||
legacy_lines.append(f"• <b>{k}</b>: {servers_list}")
|
||||
lines.append("<blockquote>" + "\n".join(legacy_lines) + "</blockquote>")
|
||||
lines.append("<i>Рекомендуется сбросить и настроить заново</i>")
|
||||
|
||||
lines.append("\n<b>🎁 Спецгруппы:</b>")
|
||||
has_spec = any(spec_map[k] for k in allowed)
|
||||
if has_spec:
|
||||
spec_lines = []
|
||||
for k in allowed:
|
||||
vals = sorted(set(spec_map[k]))
|
||||
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else '—'}")
|
||||
lines.append("<blockquote>" + "\n".join(spec_lines) + "</blockquote>")
|
||||
else:
|
||||
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
|
||||
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
|
||||
packed = AdminClusterCallback.unpack(callback.data)
|
||||
cluster_name = packed.data
|
||||
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
all_tariff_ids = set()
|
||||
for s in cluster_servers:
|
||||
all_tariff_ids.update(s.get("tariff_ids") or [])
|
||||
|
||||
tariffs_cache = {}
|
||||
if all_tariff_ids:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
|
||||
for t in result.scalars().all():
|
||||
tariffs_cache[t.id] = {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"subgroup_title": t.subgroup_title,
|
||||
"group_code": t.group_code,
|
||||
}
|
||||
|
||||
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"), IsAdminFilter())
|
||||
async def show_servers_for_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(f"grp_sel:{cluster_name}", []))
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"), IsAdminFilter())
|
||||
async def toggle_server_for_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||
i = int(idx_str)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
if i < 0 or i >= len(names):
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
server_name = names[i]
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if server_name in selected:
|
||||
selected.remove(server_name)
|
||||
else:
|
||||
selected.add(server_name)
|
||||
await state.update_data({key: list(selected)})
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"), IsAdminFilter())
|
||||
async def reset_group_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
await state.update_data({f"grp_sel:{cluster_name}": []})
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"), IsAdminFilter())
|
||||
async def choose_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if not selected:
|
||||
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
|
||||
return
|
||||
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
|
||||
await callback.message.edit_text(
|
||||
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
|
||||
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"), IsAdminFilter())
|
||||
async def apply_group_to_servers(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||
i = int(idx_str)
|
||||
groups = ALLOWED_GROUP_CODES
|
||||
if i < 0 or i >= len(groups):
|
||||
await callback.answer("Группа не найдена", show_alert=True)
|
||||
return
|
||||
group_code = groups[i]
|
||||
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if not selected:
|
||||
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
|
||||
return
|
||||
|
||||
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
|
||||
id_by_name = {name: sid for sid, name in rows.fetchall()}
|
||||
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
|
||||
if not server_ids:
|
||||
await callback.answer("Серверы не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
exist_rows = await session.execute(
|
||||
select(ServerSpecialgroup.server_id).where(
|
||||
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
|
||||
)
|
||||
)
|
||||
already = {r[0] for r in exist_rows.fetchall()}
|
||||
to_insert = [sid for sid in server_ids if sid not in already]
|
||||
|
||||
if to_insert:
|
||||
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
|
||||
await session.commit()
|
||||
|
||||
logger.debug(f"[apply_group_to_servers] group={group_code} server_ids={server_ids}")
|
||||
|
||||
await state.update_data({key: []})
|
||||
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при назначении группы тарифов: {e}")
|
||||
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"), IsAdminFilter())
|
||||
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||
try:
|
||||
cluster_name = callback_data.data
|
||||
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
|
||||
server_ids = [row[0] for row in res.fetchall()]
|
||||
if not server_ids:
|
||||
await callback.answer("В кластере нет серверов", show_alert=True)
|
||||
return
|
||||
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
|
||||
await session.commit()
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
await callback.message.edit_text(
|
||||
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
|
||||
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
|
||||
@@ -0,0 +1,98 @@
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from config import USE_COUNTRY_SELECTION
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database.models import Key, Server
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .base import router
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_server|"), IsAdminFilter())
|
||||
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
data = callback_query.data.split("|")
|
||||
new_server_name = data[1]
|
||||
old_server_name = data[2]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
|
||||
|
||||
await session.execute(
|
||||
delete(Server).where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
|
||||
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
final_text = base_text + (sync_reminder if use_country_selection else "")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=final_text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_cluster|"), IsAdminFilter())
|
||||
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
data = callback_query.data.split("|")
|
||||
new_cluster_name = data[1]
|
||||
old_cluster_name = data[2]
|
||||
old_server_name = data[3]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_cluster_name))
|
||||
await session.execute(update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name))
|
||||
|
||||
await session.execute(
|
||||
delete(Server).where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"✅ Ключи успешно перенесены в кластер '<b>{new_cluster_name}</b>', "
|
||||
f"сервер '<b>{old_server_name}</b>' и кластер '<b>{old_cluster_name}</b>' удалены!\n\n"
|
||||
f'⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
@@ -0,0 +1,260 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import check_unique_server_name, get_servers
|
||||
from database.models import Server
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .base import AdminClusterStates, router
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_clusters_editor_kb,
|
||||
build_panel_type_kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "clusters"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_servers(callback_query: CallbackQuery, session: AsyncSession):
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<blockquote>"
|
||||
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
|
||||
"</blockquote>\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
message = callback_query.message
|
||||
markup = build_clusters_editor_kb(servers)
|
||||
|
||||
if message and message.text:
|
||||
await message.edit_text(text=text, reply_markup=markup)
|
||||
else:
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await message.answer(text=text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя должно быть уникальным!</b>\n"
|
||||
"<b>Имя не должно превышать 12 символов!</b>\n\n"
|
||||
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
if len(message.text) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
cluster_name = message.text.strip()
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
server_name = message.text.strip()
|
||||
|
||||
if len(server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
if not await check_unique_server_name(session, server_name, cluster_name):
|
||||
await message.answer(
|
||||
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
|
||||
"ℹ️ <b>Формат для 3X-UI:</b>\n"
|
||||
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
|
||||
"ℹ️ <b>Формат для Remnawave:</b>\n"
|
||||
"<code>https://your-domain.com/api</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_api_url)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: Message, state: FSMContext):
|
||||
api_url = message.text.strip().rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
|
||||
await state.update_data(api_url=api_url)
|
||||
|
||||
text = (
|
||||
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Если вы используете Remnawave — введите <code>0</code>\n\n"
|
||||
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
|
||||
async def handle_subscription_url_input(message: Message, state: FSMContext):
|
||||
raw = message.text.strip()
|
||||
subscription_url = None if raw == "0" else raw.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
|
||||
await state.update_data(subscription_url=subscription_url)
|
||||
|
||||
await message.answer(
|
||||
text=f"<b>Введите inbound_id/Squads для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
f"Для Remnawave это UUID Squads, для 3x-ui — просто ID (например, <code>1</code>).",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
await state.update_data(inbound_id=inbound_id)
|
||||
|
||||
await message.answer(
|
||||
text=(
|
||||
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
|
||||
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
|
||||
"Поддержка режима выбора стран — <b>ограничена</b>."
|
||||
),
|
||||
reply_markup=build_panel_type_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_panel_type_selection(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
api_url = user_data.get("api_url")
|
||||
subscription_url = user_data.get("subscription_url")
|
||||
inbound_id = user_data.get("inbound_id")
|
||||
|
||||
result = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1))
|
||||
row = result.first()
|
||||
tariff_group = row[0] if row else None
|
||||
|
||||
new_server = Server(
|
||||
cluster_name=cluster_name,
|
||||
server_name=server_name,
|
||||
api_url=api_url,
|
||||
subscription_url=subscription_url,
|
||||
inbound_id=inbound_id,
|
||||
panel_type=panel_type,
|
||||
tariff_group=tariff_group,
|
||||
)
|
||||
|
||||
session.add(new_server)
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
from ..servers.keyboard import AdminServerCallback
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
|
||||
@@ -65,7 +66,7 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
@@ -75,6 +76,16 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
|
||||
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📋 Привязать тарифы",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Спецгруппы",
|
||||
@@ -87,18 +98,25 @@ def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Подгруппы",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
),
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_legacy_reset_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
text="🧹 Сбросить привязки",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -128,7 +146,7 @@ def build_select_subgroup_servers_kb(
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📚 Выбрать подгруппу",
|
||||
text="📋 Выбрать тарифы",
|
||||
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
@@ -140,8 +158,8 @@ def build_select_subgroup_servers_kb(
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -165,6 +183,52 @@ def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str])
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[int]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
grouped: dict[str | None, list] = {}
|
||||
for t in tariffs:
|
||||
subgroup = t.subgroup_title
|
||||
grouped.setdefault(subgroup, []).append(t)
|
||||
|
||||
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
|
||||
|
||||
for subgroup in subgroups_sorted:
|
||||
tariffs_list = grouped[subgroup]
|
||||
|
||||
if subgroup:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"━━ {subgroup} ━━",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
for t in tariffs_list:
|
||||
mark = "✅" if t.id in selected else "⬜️"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{mark} {t.name}",
|
||||
callback_data=AdminClusterCallback(action="toggle_tariff", data=f"{cluster_name}|{t.id}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Применить",
|
||||
callback_data=AdminClusterCallback(action="apply_tariffs", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к выбору серверов",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
@@ -210,7 +274,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="clusters").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -258,7 +322,7 @@ def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, s
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
@@ -300,7 +364,7 @@ def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, sele
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
@@ -320,9 +384,27 @@ def build_tariff_group_selection_for_servers_kb(
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_availability_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔁 Обновить",
|
||||
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
@@ -28,14 +28,15 @@ from .keyboard import (
|
||||
format_coupons_list,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_type = State()
|
||||
waiting_for_coupon_audience = State()
|
||||
waiting_for_balance_data = State()
|
||||
waiting_for_days_data = State()
|
||||
waiting_for_percent_data = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -55,6 +56,7 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
|
||||
kb.button(text="⏳ Время", callback_data="coupon_type_days")
|
||||
kb.button(text="📉 Процент", callback_data="coupon_type_percent")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
@@ -62,34 +64,82 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_balance")
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
async def show_coupon_audience_step(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = "🎯 <b>Кому доступен купон?</b>"
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="👤 Всем", callback_data="coupon_audience_all")
|
||||
kb.button(text="🆕 Только новым", callback_data="coupon_audience_new")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_audience)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days")
|
||||
@router.callback_query(F.data == "coupon_type_balance", IsAdminFilter())
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(coupon_type="balance")
|
||||
await show_coupon_audience_step(callback_query, state)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days", IsAdminFilter())
|
||||
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(coupon_type="days", new_users_only=False)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b>\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_percent", IsAdminFilter())
|
||||
async def handle_percent_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(coupon_type="percent", new_users_only=False)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 📉 <i>процент</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'SALE20 20 10'</b>\n"
|
||||
"Где 20 — это скидка 20%\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_percent_data)
|
||||
|
||||
|
||||
@router.callback_query(F.data.in_(("coupon_audience_all", "coupon_audience_new")), IsAdminFilter())
|
||||
async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
coupon_type = data.get("coupon_type")
|
||||
if coupon_type != "balance":
|
||||
await callback_query.answer("Ошибка: режим доступен только для купонов на баланс", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(new_users_only=callback_query.data == "coupon_audience_new")
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b>\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
@@ -97,12 +147,13 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"❌ <b>Некорректный формат!</b>\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||
"Пример: <b>'COUPON1 50 5'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
@@ -112,20 +163,39 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
coupon_amount = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if coupon_amount <= 0:
|
||||
raise ValueError("Сумма должна быть больше 0")
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nСумма и лимит должны быть целыми числами больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
|
||||
data = await state.get_data()
|
||||
new_users_only = bool(data.get("new_users_only"))
|
||||
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
coupon_amount,
|
||||
usage_limit,
|
||||
days=None,
|
||||
new_users_only=new_users_only,
|
||||
percent=None,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
@@ -137,7 +207,6 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
@@ -150,12 +219,11 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈"
|
||||
"❌ <b>Некорректный формат!</b>\n🏷️ <b>код</b> ⏳ <b>дни</b> 🔢 <b>лимит</b>\nПример: <b>'DAYS10 10 50'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
@@ -165,20 +233,34 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
days = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if days <= 0:
|
||||
raise ValueError("Количество дней должно быть больше 0")
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nДни и лимит должны быть целыми числами больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
0,
|
||||
usage_limit,
|
||||
days=days,
|
||||
new_users_only=False,
|
||||
percent=None,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n"
|
||||
f"⏳ <b>{format_days(days)}</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
@@ -190,7 +272,66 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_percent_data, IsAdminFilter())
|
||||
async def handle_percent_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b>\n"
|
||||
"🏷️ <b>код</b> 📉 <b>процент</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'SALE20 20 10'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
percent = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if percent <= 0 or percent > 100:
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nПроцент должен быть 1..100, лимит — целое число больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
0,
|
||||
usage_limit,
|
||||
days=None,
|
||||
new_users_only=False,
|
||||
percent=percent,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
text = (
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n📉 Скидка: <b>{percent}%</b>\n🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
@@ -286,7 +427,6 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
return
|
||||
|
||||
coupon_code = inline_query.query.split("coupon_")[1]
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
|
||||
coupons = await get_all_coupons(session, page=1, per_page=10)
|
||||
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
|
||||
@@ -300,17 +440,39 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
)
|
||||
return
|
||||
|
||||
percent_value = coupon.get("percent")
|
||||
if percent_value is not None and int(percent_value) > 0:
|
||||
await inline_query.answer(
|
||||
results=[],
|
||||
switch_pm_text="Процентные купоны не публикуются ссылкой",
|
||||
switch_pm_parameter="coupons",
|
||||
cache_time=1,
|
||||
)
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
title = f"Купон {coupon['code']}"
|
||||
description = (
|
||||
f"Получи {coupon['amount']} рублей!"
|
||||
if coupon["amount"] > 0
|
||||
else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||
)
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
|
||||
f"👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
|
||||
days_value = coupon.get("days")
|
||||
amount_value = coupon.get("amount") or 0
|
||||
|
||||
if days_value is not None and int(days_value) > 0:
|
||||
days_int = int(days_value)
|
||||
description = f"Продли подписку на {format_days(days_int)}!"
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||
f"⏳ <b>Продление:</b> {format_days(days_int)}\n"
|
||||
f"👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
elif int(amount_value) > 0:
|
||||
amount_int = int(amount_value)
|
||||
description = f"Получи {amount_int} рублей!"
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n💰 <b>Бонус:</b> {amount_int} рублей\n👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
else:
|
||||
description = "Купон"
|
||||
message_text = f"🎫 <b>Купон:</b> {coupon['code']}\n👇 Нажми, чтобы активировать!"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="Активировать купон", url=coupon_link)
|
||||
|
||||
@@ -67,18 +67,30 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
|
||||
|
||||
def format_coupons_list(coupons: list, username_bot: str) -> str:
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
value_text = (
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
|
||||
if coupon["amount"] > 0
|
||||
else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||
text = "📜 <b>Список купонов</b>\n\n"
|
||||
|
||||
for i, coupon in enumerate(coupons, start=1):
|
||||
percent_value = coupon.get("percent")
|
||||
days_value = coupon.get("days")
|
||||
amount_value = coupon.get("amount") or 0
|
||||
|
||||
if percent_value is not None and int(percent_value) > 0:
|
||||
value_line = f"📉 <b>Скидка:</b> {int(percent_value)}%"
|
||||
elif days_value is not None and int(days_value) > 0:
|
||||
value_line = f"⏳ <b>Продление:</b> {format_days(int(days_value))}"
|
||||
elif int(amount_value) > 0:
|
||||
value_line = f"💰 <b>Баланс:</b> {int(amount_value)} ₽"
|
||||
else:
|
||||
value_line = "—"
|
||||
|
||||
text += (
|
||||
f"<blockquote>"
|
||||
f"<b>{i}. {coupon['code']}</b>\n"
|
||||
f"{value_line}\n"
|
||||
f"🔢 <b>Лимит:</b> {coupon['usage_limit']} | "
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']}\n"
|
||||
f"<code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>"
|
||||
f"</blockquote>\n\n"
|
||||
)
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"{value_text}\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
|
||||
)
|
||||
return coupon_list
|
||||
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .emoji_handler import router
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.enums import MessageEntityType
|
||||
from aiogram.types import CallbackQuery, Message, MessageEntity
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
|
||||
class AdminEmojiState(StatesGroup):
|
||||
waiting_for_custom_emoji = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def _build_back_to_emoji_menu() -> InlineKeyboardBuilder:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action="emoji").pack())
|
||||
builder.adjust(1)
|
||||
return builder
|
||||
|
||||
|
||||
def _extract_custom_emoji_ids(entities: Iterable[MessageEntity]) -> list[str]:
|
||||
ids: list[str] = []
|
||||
for ent in entities:
|
||||
if ent.type == MessageEntityType.CUSTOM_EMOJI and ent.custom_emoji_id:
|
||||
ids.append(ent.custom_emoji_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _utf16_len(text: str) -> int:
|
||||
return len(text.encode("utf-16-le")) // 2
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "emoji"), IsAdminFilter())
|
||||
async def show_emoji_menu(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminEmojiState.waiting_for_custom_emoji)
|
||||
example_id = "5201769509345588200"
|
||||
marker = f"{{{{emoji:{example_id}}}}}"
|
||||
preview_placeholder = "😀"
|
||||
text = (
|
||||
"Отправьте любое кастомное эмоджи — я верну его ID и покажу пример для текстов.\n\n"
|
||||
"Пример:\n"
|
||||
f"{preview_placeholder}"
|
||||
)
|
||||
|
||||
entities: list[MessageEntity] = []
|
||||
start = 0
|
||||
while True:
|
||||
marker_pos = text.find(marker, start)
|
||||
if marker_pos == -1:
|
||||
break
|
||||
entities.append(
|
||||
MessageEntity(
|
||||
type=MessageEntityType.CODE,
|
||||
offset=_utf16_len(text[:marker_pos]),
|
||||
length=_utf16_len(marker),
|
||||
)
|
||||
)
|
||||
start = marker_pos + len(marker)
|
||||
preview_pos = text.find(preview_placeholder)
|
||||
if preview_pos != -1:
|
||||
entities.append(
|
||||
MessageEntity(
|
||||
type=MessageEntityType.CUSTOM_EMOJI,
|
||||
offset=_utf16_len(text[:preview_pos]),
|
||||
length=_utf16_len(preview_placeholder),
|
||||
custom_emoji_id=example_id,
|
||||
)
|
||||
)
|
||||
|
||||
if hasattr(callback_query.message, "_original_edit_text"):
|
||||
await callback_query.message._original_edit_text(
|
||||
text=text,
|
||||
entities=entities,
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
entities=entities,
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
parse_mode=None,
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminEmojiState.waiting_for_custom_emoji, IsAdminFilter())
|
||||
async def handle_custom_emoji_id(message: Message, state: FSMContext):
|
||||
entities = list(message.entities or []) + list(message.caption_entities or [])
|
||||
emoji_ids = _extract_custom_emoji_ids(entities)
|
||||
|
||||
if not emoji_ids:
|
||||
await message.answer(
|
||||
"❌ Не вижу кастомных эмоджи. Отправьте именно <b>кастомный эмоджи</b> из набора.",
|
||||
reply_markup=_build_back_to_emoji_menu().as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
unique_ids: list[str] = []
|
||||
for emoji_id in emoji_ids:
|
||||
if emoji_id not in unique_ids:
|
||||
unique_ids.append(emoji_id)
|
||||
|
||||
placeholder = "😀"
|
||||
back_builder = _build_back_to_emoji_menu()
|
||||
|
||||
if len(unique_ids) == 1:
|
||||
emoji_id = unique_ids[0]
|
||||
marker = f"{{{{emoji:{emoji_id}}}}}"
|
||||
example_send = f"Ты отправил: Привет, {marker} !"
|
||||
example_recv = f"А получил: Привет, {placeholder} !"
|
||||
instruction_text = (
|
||||
"✅ ID кастомного эмоджи\n"
|
||||
f"{emoji_id}\n\n"
|
||||
"Вставляйте в файл текстов так:\n"
|
||||
f"{marker}\n\n"
|
||||
"Пример:\n"
|
||||
f"{example_send}\n"
|
||||
f"{example_recv}\n\n"
|
||||
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
|
||||
)
|
||||
preview_text = example_recv
|
||||
preview_ids = [emoji_id]
|
||||
code_markers = [marker]
|
||||
else:
|
||||
ids_text = "\n".join(f"• {emoji_id}" for emoji_id in unique_ids)
|
||||
markers_text = "\n".join(f"• {{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
|
||||
markers_inline = " ".join(f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
|
||||
example_send = f"Ты отправил: Привет, {markers_inline} !"
|
||||
example_recv = "А получил: Привет, " + " ".join(placeholder for _ in unique_ids) + " !"
|
||||
instruction_text = (
|
||||
"✅ ID кастомных эмоджи\n"
|
||||
f"{ids_text}\n\n"
|
||||
"Вставляйте в файл текстов так:\n"
|
||||
f"{markers_text}\n\n"
|
||||
"Пример:\n"
|
||||
f"{example_send}\n"
|
||||
f"{example_recv}\n\n"
|
||||
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
|
||||
)
|
||||
preview_text = example_recv
|
||||
preview_ids = unique_ids
|
||||
code_markers = [f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids]
|
||||
|
||||
full_text = instruction_text
|
||||
|
||||
entities: list[MessageEntity] = []
|
||||
for marker in code_markers:
|
||||
start = 0
|
||||
while True:
|
||||
pos = full_text.find(marker, start)
|
||||
if pos == -1:
|
||||
break
|
||||
entities.append(
|
||||
MessageEntity(
|
||||
type=MessageEntityType.CODE,
|
||||
offset=_utf16_len(full_text[:pos]),
|
||||
length=_utf16_len(marker),
|
||||
)
|
||||
)
|
||||
start = pos + len(marker)
|
||||
|
||||
for emoji_id in unique_ids:
|
||||
start = 0
|
||||
while True:
|
||||
pos = full_text.find(emoji_id, start)
|
||||
if pos == -1:
|
||||
break
|
||||
entities.append(
|
||||
MessageEntity(
|
||||
type=MessageEntityType.CODE,
|
||||
offset=_utf16_len(full_text[:pos]),
|
||||
length=_utf16_len(emoji_id),
|
||||
)
|
||||
)
|
||||
start = pos + len(emoji_id)
|
||||
preview_offset_base = _utf16_len(full_text[: full_text.index(preview_text)])
|
||||
running_utf16 = 0
|
||||
idx = 0
|
||||
for ch in preview_text:
|
||||
if ch == placeholder and idx < len(preview_ids):
|
||||
entities.append(
|
||||
MessageEntity(
|
||||
type=MessageEntityType.CUSTOM_EMOJI,
|
||||
offset=preview_offset_base + running_utf16,
|
||||
length=_utf16_len(placeholder),
|
||||
custom_emoji_id=str(preview_ids[idx]),
|
||||
)
|
||||
)
|
||||
idx += 1
|
||||
running_utf16 += _utf16_len(ch)
|
||||
|
||||
if hasattr(message, "_original_answer"):
|
||||
await message._original_answer(
|
||||
text=full_text,
|
||||
entities=entities,
|
||||
reply_markup=back_builder.as_markup(),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await message.answer(text=full_text, reply_markup=back_builder.as_markup(), parse_mode=None)
|
||||
await state.clear()
|
||||
@@ -8,13 +8,15 @@ 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 filters.admin import IsAdminFilter
|
||||
from database.models import Gift, GiftUsage
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||
from handlers.utils import edit_or_send_message, format_days, format_months
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -25,14 +27,14 @@ class GiftCreationState(StatesGroup):
|
||||
waiting_for_limit_input_or_unlimited = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"))
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"), IsAdminFilter())
|
||||
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")
|
||||
@router.callback_query(F.data == "admin_gift_create", IsAdminFilter())
|
||||
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
|
||||
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
@@ -40,7 +42,7 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
|
||||
|
||||
if not tariffs:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
@@ -73,12 +75,12 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
builder.row(types.InlineKeyboardButton(text=BACK, 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|"))
|
||||
@router.callback_query(F.data.startswith("admin_gift_subgroup|"), IsAdminFilter())
|
||||
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
subgroup_hash = callback.data.split("|", 1)[1]
|
||||
@@ -108,7 +110,7 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
|
||||
builder.row(types.InlineKeyboardButton(text=BACK, callback_data="admin_gift_create"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
@@ -121,20 +123,20 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
|
||||
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_gift_select|"))
|
||||
@router.callback_query(F.data.startswith("admin_gift_select|"), IsAdminFilter())
|
||||
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")
|
||||
kb.button(text=BACK, callback_data="admin_gift_create")
|
||||
await callback.message.edit_text(
|
||||
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "gift_limit_unlimited")
|
||||
@router.callback_query(F.data == "gift_limit_unlimited", IsAdminFilter())
|
||||
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
|
||||
from handlers.payments.gift import finalize_gift
|
||||
|
||||
@@ -144,7 +146,7 @@ async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot:
|
||||
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
|
||||
|
||||
|
||||
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
|
||||
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited, IsAdminFilter())
|
||||
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
|
||||
from handlers.payments.gift import finalize_gift
|
||||
|
||||
@@ -162,12 +164,12 @@ async def handle_limited_gift_input(message: types.Message, session: AsyncSessio
|
||||
await finalize_gift(message, session, bot, data, is_unlimited=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_gifts_all")
|
||||
@router.callback_query(F.data == "admin_gifts_all", IsAdminFilter())
|
||||
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|"))
|
||||
@router.callback_query(F.data.startswith("gifts_page|"), IsAdminFilter())
|
||||
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
|
||||
page = int(callback.data.split("|")[1])
|
||||
await show_gift_list(callback, session, page)
|
||||
@@ -181,47 +183,21 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not gifts:
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=BACK, 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())
|
||||
await callback.message.edit_text(
|
||||
f"🎁 <b>Список подарков</b>\nСтраница {page}:",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
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|"))
|
||||
@router.callback_query(F.data.startswith("gift_view|"), IsAdminFilter())
|
||||
async def view_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
@@ -254,12 +230,12 @@ async def view_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
|
||||
builder.button(text="🔙 Назад", callback_data="admin_gifts_all")
|
||||
builder.button(text=BACK, callback_data="admin_gifts_all")
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gift_delete|"))
|
||||
@router.callback_query(F.data.startswith("gift_delete|"), IsAdminFilter())
|
||||
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database.models import Gift, Tariff
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days, format_months
|
||||
from handlers.utils import format_days
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
@@ -27,7 +27,7 @@ def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
|
||||
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.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -37,11 +37,8 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
|
||||
row = []
|
||||
|
||||
for i, gift in enumerate(gifts):
|
||||
if gift.selected_months > 0:
|
||||
duration_text = format_months(gift.selected_months)
|
||||
else:
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
|
||||
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
|
||||
|
||||
@@ -64,6 +61,6 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
|
||||
if nav:
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
__all__ = ("router",)
|
||||
from aiogram import Router
|
||||
|
||||
from .management_handler import router
|
||||
|
||||
router = Router()
|
||||
|
||||
from . import (
|
||||
admins, # noqa: F401
|
||||
database, # noqa: F401
|
||||
domain, # noqa: F401
|
||||
file_upload, # noqa: F401
|
||||
import_3xui, # noqa: F401
|
||||
import_remnawave, # noqa: F401
|
||||
maintenance, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import hashlib
|
||||
|
||||
from asyncio import sleep
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Admin
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from . import router
|
||||
from .keyboard import (
|
||||
AdminPanelCallback,
|
||||
build_admin_back_kb_to_admins,
|
||||
build_admins_kb,
|
||||
build_role_selection_kb,
|
||||
build_single_admin_menu,
|
||||
build_token_result_kb,
|
||||
)
|
||||
|
||||
|
||||
class AdminState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"), IsAdminFilter())
|
||||
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"), IsAdminFilter())
|
||||
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, IsAdminFilter())
|
||||
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|")), IsAdminFilter())
|
||||
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|")), IsAdminFilter())
|
||||
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|")), IsAdminFilter())
|
||||
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|")), IsAdminFilter())
|
||||
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|")), IsAdminFilter())
|
||||
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from aiogram import Bot, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_database_kb, build_export_db_sources_kb
|
||||
|
||||
|
||||
class DatabaseState(StatesGroup):
|
||||
waiting_for_backup_file = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "database"), IsAdminFilter())
|
||||
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"), IsAdminFilter())
|
||||
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, IsAdminFilter())
|
||||
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"), IsAdminFilter())
|
||||
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"), IsAdminFilter())
|
||||
async def back_to_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
||||
@@ -0,0 +1,79 @@
|
||||
import re
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||
await callback_query.message.edit_text(
|
||||
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminManagementStates.waiting_for_new_domain, IsAdminFilter())
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
|
||||
new_domain = message.text.strip()
|
||||
|
||||
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
|
||||
try:
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при обновлении домена: {e}",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
|
||||
example = sample.fetchone()
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Домен успешно изменен на {new_domain}!",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
class FileUploadState(StatesGroup):
|
||||
waiting_for_target = State()
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"), IsAdminFilter())
|
||||
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"📤 <b>Загрузка файла</b>\n\n"
|
||||
"Вы можете заменить файл в корневой директории бота или в папке <code>handlers</code>.\n\n"
|
||||
"📁 Выберите директорию, а затем отправьте файл с таким же именем и расширением, "
|
||||
"как у уже существующего файла. Он будет автоматически заменён."
|
||||
)
|
||||
|
||||
back_kb = build_admin_back_kb("management")
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="📁 Корень бота", callback_data="upload_target:root")
|
||||
kb.button(text="📂 Папка handlers", callback_data="upload_target:handlers")
|
||||
for row in back_kb.inline_keyboard:
|
||||
kb.row(*row)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=kb.as_markup(),
|
||||
)
|
||||
await state.set_state(FileUploadState.waiting_for_target)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("upload_target:"), FileUploadState.waiting_for_target, IsAdminFilter())
|
||||
async def select_upload_target(callback: CallbackQuery, state: FSMContext):
|
||||
target = callback.data.split(":", 1)[1]
|
||||
if target not in {"root", "handlers"}:
|
||||
await callback.answer("Неизвестная директория.")
|
||||
return
|
||||
|
||||
await state.update_data(upload_target=target)
|
||||
|
||||
target_text = "Корень бота" if target == "root" else "Папка handlers"
|
||||
await callback.message.edit_text(
|
||||
"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Выбрана директория: <b>{target_text}</b>.\n\n"
|
||||
"Теперь отправьте файл с таким же именем и расширением, как у уже существующего файла. "
|
||||
"Он будет автоматически заменён.",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.set_state(FileUploadState.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(FileUploadState.waiting_for_file, F.document, IsAdminFilter())
|
||||
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
||||
document = message.document
|
||||
file_name = document.file_name
|
||||
|
||||
if not file_name or "." not in file_name:
|
||||
await message.answer("❌ У файла должно быть имя с расширением.")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get("upload_target", "root")
|
||||
|
||||
if target == "handlers":
|
||||
base_dir = os.path.abspath("./handlers")
|
||||
else:
|
||||
base_dir = os.path.abspath(".")
|
||||
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
dest_path = os.path.join(base_dir, file_name)
|
||||
|
||||
try:
|
||||
await message.bot.download(document, destination=dest_path)
|
||||
|
||||
back_kb = build_admin_back_kb("management")
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(
|
||||
text="🔁 Перезагрузить бота",
|
||||
callback_data=AdminPanelCallback(action="restart").pack(),
|
||||
)
|
||||
for row in back_kb.inline_keyboard:
|
||||
kb.row(*row)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён в директории <code>{target}</code>.\n\n"
|
||||
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
||||
reply_markup=kb.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
||||
await message.answer(
|
||||
f"❌ Не удалось сохранить файл: {e}",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -0,0 +1,89 @@
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from database.models import Key
|
||||
from handlers.keys.operations import update_subscription
|
||||
from logger import logger
|
||||
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_post_import_kb
|
||||
|
||||
|
||||
class Import3xuiStates(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"), IsAdminFilter())
|
||||
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, IsAdminFilter())
|
||||
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"), IsAdminFilter())
|
||||
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
||||
|
||||
result = await session.execute(select(Key.tg_id, Key.email))
|
||||
keys = result.all()
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for tg_id, email in keys:
|
||||
try:
|
||||
await update_subscription(tg_id=tg_id, email=email, session=session)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
||||
failed += 1
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.types import CallbackQuery
|
||||
from dateutil import parser
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from database.models import Key, Server, User
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback, build_back_to_db_menu
|
||||
|
||||
|
||||
def extract_tg_id_from_username(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
match = re.search(r"_(\d+)(?:\D|$)", value)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
tg_id = int(match.group(1))
|
||||
if tg_id <= 0:
|
||||
return None
|
||||
|
||||
return tg_id
|
||||
|
||||
|
||||
def extract_tg_id_from_user_payload(user: dict) -> int | None:
|
||||
tg_id = user.get("telegramId")
|
||||
|
||||
if isinstance(tg_id, int):
|
||||
if tg_id > 0:
|
||||
return tg_id
|
||||
return None
|
||||
|
||||
if isinstance(tg_id, str):
|
||||
tg_id = tg_id.strip()
|
||||
if tg_id.isdigit():
|
||||
tg_id_int = int(tg_id)
|
||||
return tg_id_int if tg_id_int > 0 else None
|
||||
|
||||
tg_id = extract_tg_id_from_username(user.get("username")) or extract_tg_id_from_username(user.get("email"))
|
||||
return tg_id
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"), IsAdminFilter())
|
||||
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
||||
servers = result.scalars().all()
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных Remnawave-серверов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
server = servers[0]
|
||||
|
||||
api = RemnawaveAPI(base_url=server.api_url)
|
||||
|
||||
users = await api.get_all_users_time(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"📭 На панели нет клиентов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
||||
|
||||
added_users = await import_remnawave_users(session, users)
|
||||
|
||||
server_id = server.cluster_name or server.server_name
|
||||
|
||||
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
||||
|
||||
preview = ""
|
||||
for i, user in enumerate(users[:3], 1):
|
||||
email = user.get("email") or user.get("username") or "-"
|
||||
expire = (user.get("expireAt") or "")[: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 = extract_tg_id_from_user_payload(user)
|
||||
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 = extract_tg_id_from_user_payload(user)
|
||||
|
||||
client_id = user.get("uuid")
|
||||
email = user.get("email") or user.get("username")
|
||||
remnawave_link = user.get("subscriptionUrl")
|
||||
expire_at = user.get("expireAt")
|
||||
created_at = user.get("createdAt")
|
||||
|
||||
if not tg_id or not client_id:
|
||||
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
||||
continue
|
||||
|
||||
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if exists_stmt.scalar():
|
||||
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
||||
continue
|
||||
|
||||
try:
|
||||
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
||||
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_ts,
|
||||
expiry_time=expire_ts,
|
||||
key="",
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=None,
|
||||
is_frozen=False,
|
||||
alias=None,
|
||||
notified=False,
|
||||
notified_24h=False,
|
||||
)
|
||||
session.add(new_key)
|
||||
added += 1
|
||||
|
||||
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
||||
return added
|
||||
@@ -1,7 +1,8 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from middlewares import maintenance
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
@@ -39,7 +40,9 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
text="📤 Загрузить файл",
|
||||
callback_data=AdminPanelCallback(action="upload_file").pack(),
|
||||
)
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
||||
|
||||
maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
|
||||
builder.button(
|
||||
text=maintenance_text,
|
||||
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||
@@ -72,7 +75,8 @@ def build_database_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -81,7 +85,7 @@ def build_export_db_sources_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
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.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -105,7 +109,6 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
|
||||
|
||||
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
|
||||
|
||||
if role == "superadmin":
|
||||
@@ -113,7 +116,7 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
|
||||
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
|
||||
)
|
||||
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action="admins").pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -123,14 +126,14 @@ def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
|
||||
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.button(text=BACK, callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -138,24 +141,16 @@ def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
|
||||
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.button(text=BACK, 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.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from aiogram import F
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.bootstrap import MANAGEMENT_CONFIG, update_management_config
|
||||
from database.models import Admin
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from . import router
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(admin.role),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"), IsAdminFilter())
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
current_config = dict(MANAGEMENT_CONFIG)
|
||||
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
|
||||
new_value = not current_value
|
||||
current_config["MAINTENANCE_ENABLED"] = new_value
|
||||
|
||||
await update_management_config(session, current_config)
|
||||
|
||||
new_status = "включён" if new_value else "выключен"
|
||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
|
||||
@@ -1,687 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from dateutil import parser
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from database.models import Admin, Key, Server, User
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import update_subscription
|
||||
from logger import logger
|
||||
from middlewares import maintenance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminPanelCallback,
|
||||
build_admin_back_kb_to_admins,
|
||||
build_admins_kb,
|
||||
build_back_to_db_menu,
|
||||
build_database_kb,
|
||||
build_export_db_sources_kb,
|
||||
build_management_kb,
|
||||
build_post_import_kb,
|
||||
build_role_selection_kb,
|
||||
build_single_admin_menu,
|
||||
build_token_result_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
class Import3xuiStates(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class FileUploadState(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class DatabaseState(StatesGroup):
|
||||
waiting_for_backup_file = State()
|
||||
|
||||
|
||||
class AdminState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(admin.role),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
"""Запрашивает у администратора новый домен."""
|
||||
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||
await callback_query.message.edit_text(
|
||||
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
|
||||
"""Обновляет домен в таблице keys."""
|
||||
new_domain = message.text.strip()
|
||||
|
||||
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
|
||||
try:
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при обновлении домена: {e}",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
|
||||
example = sample.fetchone()
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Домен успешно изменен на {new_domain}!",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
|
||||
async def show_admins(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Admin.tg_id, Admin.role))
|
||||
admins = result.all()
|
||||
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
|
||||
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
await state.set_state(AdminState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.message(AdminState.waiting_for_tg_id)
|
||||
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
|
||||
try:
|
||||
tg_id = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
if result.scalar_one_or_none():
|
||||
await message.answer("⚠️ Такой админ уже существует.")
|
||||
else:
|
||||
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
|
||||
await session.commit()
|
||||
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
|
||||
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
role = admin or "moderator"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
|
||||
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
token = Admin.generate_token()
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
admin.token = token_hash
|
||||
await session.commit()
|
||||
|
||||
msg = await callback.message.edit_text(
|
||||
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
|
||||
f"<code>{token}</code>\n\n"
|
||||
f"⚠️ Это сообщение исчезнет через 5 минут.",
|
||||
reply_markup=build_token_result_kb(token),
|
||||
)
|
||||
|
||||
await sleep(300)
|
||||
try:
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
|
||||
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
await callback.message.edit_text(
|
||||
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
|
||||
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
try:
|
||||
_, tg_id_str, role = callback_data.action.split("|")
|
||||
tg_id = int(tg_id_str)
|
||||
if role not in ("superadmin", "moderator"):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await callback.message.edit_text("❌ Неверный формат.")
|
||||
return
|
||||
|
||||
if tg_id == callback.from_user.id:
|
||||
await callback.message.edit_text(
|
||||
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
admin.role = role
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
|
||||
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
|
||||
async def handle_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text="🗄 <b>Управление базой данных</b>",
|
||||
reply_markup=build_database_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
|
||||
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
|
||||
"⚠️ Все текущие данные будут перезаписаны.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(DatabaseState.waiting_for_backup_file)
|
||||
|
||||
|
||||
@router.message(DatabaseState.waiting_for_backup_file)
|
||||
async def restore_database(message: Message, state: FSMContext, bot: Bot):
|
||||
document = message.document
|
||||
|
||||
if not document or not document.file_name.endswith(".sql"):
|
||||
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
|
||||
return
|
||||
|
||||
try:
|
||||
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
await bot.download(document, destination=tmp_path)
|
||||
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
|
||||
|
||||
is_custom_dump = False
|
||||
with open(tmp_path, "rb") as f:
|
||||
signature = f.read(5)
|
||||
if signature == b"PGDMP":
|
||||
is_custom_dump = True
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-u",
|
||||
"postgres",
|
||||
"psql",
|
||||
"-d",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
logger.info("[Restore] База данных пересоздана")
|
||||
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
if is_custom_dump:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pg_restore",
|
||||
f"--dbname={DB_NAME}",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"--no-owner",
|
||||
"--exit-on-error",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-d",
|
||||
DB_NAME,
|
||||
"-f",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
|
||||
)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"✅ База данных восстановлена.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
|
||||
await state.clear()
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
|
||||
await message.answer(
|
||||
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
|
||||
async def handle_export_db(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
"📤 Выберите панель, с которой требуется получить данные:\n\n"
|
||||
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
|
||||
reply_markup=build_export_db_sources_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
|
||||
async def back_to_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
|
||||
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
||||
servers = result.scalars().all()
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных Remnawave-серверов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
server = servers[0]
|
||||
|
||||
api = RemnawaveAPI(base_url=server.api_url)
|
||||
|
||||
users = await api.get_all_users_time(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"📭 На панели нет клиентов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
||||
|
||||
added_users = await import_remnawave_users(session, users)
|
||||
|
||||
server_id = server.cluster_name or server.server_name
|
||||
|
||||
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
||||
|
||||
preview = ""
|
||||
for i, user in enumerate(users[:3], 1):
|
||||
email = user.get("email") or user.get("username") or "-"
|
||||
expire = user.get("expireAt", "")[:10]
|
||||
preview += f"{i}. {email} — до {expire}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
|
||||
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
|
||||
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
|
||||
f"<b>Первые 3:</b>\n{preview}",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
if not tg_id:
|
||||
continue
|
||||
|
||||
exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
if exists.scalar():
|
||||
continue
|
||||
|
||||
try:
|
||||
new_user = User(
|
||||
tg_id=tg_id,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code=None,
|
||||
is_bot=False,
|
||||
balance=0.0,
|
||||
trial=1,
|
||||
source_code=None,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
session.add(new_user)
|
||||
added += 1
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
await session.commit()
|
||||
return added
|
||||
|
||||
|
||||
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
client_id = user.get("uuid")
|
||||
email = user.get("email") or user.get("username")
|
||||
remnawave_link = user.get("subscriptionUrl")
|
||||
expire_at = user.get("expireAt")
|
||||
created_at = user.get("createdAt")
|
||||
|
||||
if not tg_id or not client_id:
|
||||
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
||||
continue
|
||||
|
||||
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if exists_stmt.scalar():
|
||||
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
||||
continue
|
||||
|
||||
try:
|
||||
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
||||
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_ts,
|
||||
expiry_time=expire_ts,
|
||||
key="",
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=None,
|
||||
is_frozen=False,
|
||||
alias=None,
|
||||
notified=False,
|
||||
notified_24h=False,
|
||||
)
|
||||
session.add(new_key)
|
||||
added += 1
|
||||
|
||||
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
||||
return added
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
|
||||
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
|
||||
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
|
||||
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
|
||||
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(Import3xuiStates.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(Import3xuiStates.waiting_for_file, F.document)
|
||||
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
|
||||
file = message.document
|
||||
|
||||
if not file.file_name.endswith(".db"):
|
||||
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
|
||||
return
|
||||
|
||||
file_path = f"/tmp/{file.file_name}"
|
||||
await message.bot.download(file, destination=file_path)
|
||||
|
||||
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
|
||||
|
||||
try:
|
||||
from database.importer import import_keys_from_3xui_db
|
||||
|
||||
imported, skipped = await import_keys_from_3xui_db(file_path, session)
|
||||
|
||||
await processing_message.edit_text(
|
||||
f"✅ Восстановление завершено:\n"
|
||||
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
|
||||
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
|
||||
reply_markup=build_post_import_kb(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Import 3x-ui] Ошибка: {e}")
|
||||
await processing_message.edit_text(
|
||||
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
|
||||
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
||||
|
||||
result = await session.execute(select(Key.tg_id, Key.email))
|
||||
keys = result.all()
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for tg_id, email in keys:
|
||||
try:
|
||||
await update_subscription(tg_id=tg_id, email=email, session=session)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
||||
failed += 1
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
|
||||
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📤 <b>Загрузка файла</b>\n\n"
|
||||
"Вы можете заменить файл в корневой директории бота.\n\n"
|
||||
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
|
||||
"как у уже существующего файла. Он будет автоматически заменён.",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.set_state(FileUploadState.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(FileUploadState.waiting_for_file, F.document)
|
||||
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
||||
document = message.document
|
||||
file_name = document.file_name
|
||||
|
||||
if not file_name or "." not in file_name:
|
||||
await message.answer("❌ У файла должно быть имя с расширением.")
|
||||
return
|
||||
|
||||
dest_path = os.path.abspath(f"./{file_name}")
|
||||
|
||||
try:
|
||||
await message.bot.download(document, destination=dest_path)
|
||||
await message.answer(
|
||||
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
|
||||
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
||||
await message.answer(
|
||||
f"❌ Не удалось сохранить файл: {e}",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -29,7 +29,7 @@ def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | N
|
||||
if page > 1:
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
|
||||
)
|
||||
)
|
||||
@@ -83,11 +83,21 @@ def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
|
||||
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="⬆️ Загрузить settings.py",
|
||||
callback_data=AdminPanelCallback(action="module_upload_settings", page=page).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="⬆️ Загрузить texts.py",
|
||||
callback_data=AdminPanelCallback(action="module_upload_texts", page=page).pack(),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку",
|
||||
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -23,9 +21,12 @@ def list_installed_modules() -> list[tuple[str, str | None]]:
|
||||
if not os.path.isdir(base):
|
||||
return []
|
||||
items: list[tuple[str, str | None]] = []
|
||||
for name in sorted(os.listdir(base)):
|
||||
path = os.path.join(base, name)
|
||||
if os.path.isdir(path) and not name.startswith("."):
|
||||
for raw_name in sorted(os.listdir(base)):
|
||||
path = os.path.join(base, raw_name)
|
||||
if os.path.isdir(path) and not raw_name.startswith("."):
|
||||
name = (raw_name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
ver = None
|
||||
vp = os.path.join(path, "VERSION")
|
||||
if os.path.isfile(vp):
|
||||
|
||||
@@ -21,62 +21,116 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||
|
||||
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
is_super = admin_role == "superadmin"
|
||||
is_moderator = admin_role == "moderator"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack()
|
||||
text="👤 Поиск пользователя",
|
||||
callback_data=AdminPanelCallback(action="search_user").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔑 Поиск подписок",
|
||||
callback_data=AdminPanelCallback(action="search_key").pack(),
|
||||
),
|
||||
InlineKeyboardButton(text="🔑 Поиск подписок", callback_data=AdminPanelCallback(action="search_key").pack()),
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.button(
|
||||
text="🖥️ Управление серверами",
|
||||
callback_data=AdminPanelCallback(action="clusters").pack(),
|
||||
if is_super:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🖥️ Управление серверами",
|
||||
callback_data=AdminPanelCallback(action="clusters").pack(),
|
||||
)
|
||||
)
|
||||
builder.button(
|
||||
text="💸Управление тарифами",
|
||||
callback_data=AdminPanelCallback(action="tariffs").pack(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💸Управление тарифами",
|
||||
callback_data=AdminPanelCallback(action="tariffs").pack(),
|
||||
)
|
||||
)
|
||||
builder.button(
|
||||
text="🤖 Управление ботом",
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
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()),
|
||||
InlineKeyboardButton(
|
||||
text="📢 Рассылка",
|
||||
callback_data=AdminPanelCallback(action="sender").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Купоны",
|
||||
callback_data=AdminPanelCallback(action="coupons").pack(),
|
||||
),
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
if is_super:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
|
||||
InlineKeyboardButton(text="🧩 Мои модули", callback_data=AdminPanelCallback(action="modules").pack()),
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Подарки",
|
||||
callback_data=AdminPanelCallback(action="gifts").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧩 Мои модули",
|
||||
callback_data=AdminPanelCallback(action="modules").pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=AdminPanelCallback(action="stats").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📈 Аналитика",
|
||||
callback_data=AdminPanelCallback(action="ads").pack(),
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.button(
|
||||
text="🎁 Подарки",
|
||||
callback_data=AdminPanelCallback(action="gifts").pack(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Подарки",
|
||||
callback_data=AdminPanelCallback(action="gifts").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
builder.button(
|
||||
text=MAIN_MENU,
|
||||
callback_data="profile",
|
||||
)
|
||||
if not is_moderator:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⚙️ Настройки",
|
||||
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
|
||||
else:
|
||||
builder.adjust(2, 2, 1, 1)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
return builder.as_markup()
|
||||
markup = builder.as_markup()
|
||||
|
||||
if is_super:
|
||||
ads_callback = AdminPanelCallback(action="ads").pack()
|
||||
emoji_button = InlineKeyboardButton(
|
||||
text="😀 Эмоджи",
|
||||
callback_data=AdminPanelCallback(action="emoji").pack(),
|
||||
)
|
||||
|
||||
inserted = False
|
||||
for index, row in enumerate(markup.inline_keyboard):
|
||||
for button in row:
|
||||
if getattr(button, "callback_data", None) == ads_callback:
|
||||
markup.inline_keyboard.insert(index + 1, [emoji_button])
|
||||
inserted = True
|
||||
break
|
||||
if inserted:
|
||||
break
|
||||
|
||||
if not inserted:
|
||||
markup.inline_keyboard.append([emoji_button])
|
||||
|
||||
return markup
|
||||
|
||||
|
||||
def build_restart_kb() -> InlineKeyboardMarkup:
|
||||
@@ -105,4 +159,7 @@ 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(),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .sender_handler import router
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,230 +1,24 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy import distinct, exists, func, not_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_blocked_user
|
||||
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
|
||||
from database.models import Server
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||
from .sender_service import BroadcastService
|
||||
from .sender_states import AdminSender
|
||||
from .sender_utils import get_recipients, parse_message_buttons
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def try_add_blocked_user(tg_id: int, session: AsyncSession):
|
||||
if session:
|
||||
try:
|
||||
await create_blocked_user(session, tg_id)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
|
||||
|
||||
|
||||
async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
|
||||
results = []
|
||||
min_interval = 1.0 / 15
|
||||
|
||||
for msg in messages:
|
||||
tg_id = msg["tg_id"]
|
||||
text = msg["text"]
|
||||
photo = msg.get("photo")
|
||||
keyboard = msg.get("keyboard")
|
||||
|
||||
try:
|
||||
if photo:
|
||||
await bot.send_photo(chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_in = int(e.retry_after) + 1
|
||||
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
|
||||
await asyncio.sleep(e.retry_after)
|
||||
try:
|
||||
if photo:
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
results.append(False)
|
||||
except TelegramBadRequest as bad_request:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as retry_error:
|
||||
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
|
||||
results.append(False)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
results.append(False)
|
||||
except TelegramBadRequest as bad_request:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
|
||||
results.append(False)
|
||||
|
||||
await asyncio.sleep(min_interval)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
preview = State()
|
||||
|
||||
|
||||
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str = None) -> tuple[list[int], int]:
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
banned_tg_ids = select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
|
||||
)
|
||||
|
||||
query = None
|
||||
if send_to == "subscribed":
|
||||
query = (
|
||||
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
.outerjoin(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.count(Key.tg_id) == 0)
|
||||
.union_all(
|
||||
select(User.tg_id)
|
||||
.join(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.max(Key.expiry_time) <= now_ms)
|
||||
)
|
||||
)
|
||||
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
|
||||
elif send_to == "untrial":
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "cluster":
|
||||
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)
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "hotleads":
|
||||
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Payment, User.tg_id == Payment.tg_id)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "trial":
|
||||
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
query = (
|
||||
select(distinct(Key.tg_id))
|
||||
.where(Key.tariff_id.in_(trial_tariff_subquery))
|
||||
.where(~Key.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
else:
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
|
||||
|
||||
result = await session.execute(query)
|
||||
tg_ids = [row[0] for row in result.all()]
|
||||
return tg_ids, len(tg_ids)
|
||||
|
||||
|
||||
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(
|
||||
AdminPanelCallback.filter(F.action == "sender"),
|
||||
IsAdminFilter(),
|
||||
@@ -237,18 +31,30 @@ async def handle_sender(callback_query: CallbackQuery):
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
|
||||
logger.debug("[Sender] Сообщение не изменено")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminSenderCallback.filter(F.type == "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_cluster_select(callback_query: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server.cluster_name).distinct())
|
||||
clusters = result.mappings().all()
|
||||
|
||||
await callback_query.message.answer(
|
||||
"✍️ Выберите кластер для рассылки сообщений:",
|
||||
reply_markup=build_clusters_kb(clusters),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminSenderCallback.filter(F.type != "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback_text(
|
||||
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
|
||||
):
|
||||
async def handle_broadcast_type(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
"✍️ Введите текст сообщения для рассылки\n\n"
|
||||
@@ -272,20 +78,6 @@ async def handle_sender_callback_text(
|
||||
await state.set_state(AdminSender.waiting_for_message)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminSenderCallback.filter(F.type == "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server.cluster_name).distinct())
|
||||
clusters = result.mappings().all()
|
||||
|
||||
await callback_query.message.answer(
|
||||
"✍️ Выберите кластер для рассылки сообщений:",
|
||||
reply_markup=build_clusters_kb(clusters),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||
async def handle_message_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
original_text = message.html_text or message.text or message.caption or ""
|
||||
@@ -307,6 +99,21 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
|
||||
cluster_name = data.get("cluster_name")
|
||||
_, user_count = await get_recipients(session, send_to, cluster_name)
|
||||
|
||||
if keyboard:
|
||||
try:
|
||||
keyboard_dict = keyboard.model_dump()
|
||||
InlineKeyboardMarkup.model_validate(keyboard_dict)
|
||||
except Exception as e:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в клавиатуре!</b>\n\n"
|
||||
f"Не удалось сохранить клавиатуру из указанных кнопок.\n"
|
||||
f"Ошибка: {str(e)}\n\n"
|
||||
f"Пожалуйста, проверьте формат кнопок и попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
|
||||
await state.set_state(AdminSender.preview)
|
||||
|
||||
@@ -320,16 +127,16 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
|
||||
InlineKeyboardButton(text="📤 Отправить", callback_data="send_broadcast"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_broadcast"),
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_message", IsAdminFilter())
|
||||
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
@router.callback_query(F.data == "send_broadcast", IsAdminFilter())
|
||||
async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
text_message = data.get("text")
|
||||
photo = data.get("photo")
|
||||
@@ -342,10 +149,27 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
|
||||
try:
|
||||
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления клавиатуры: {e}")
|
||||
logger.error(f"[Sender] Ошибка восстановления клавиатуры: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
f"❌ <b>Ошибка восстановления клавиатуры!</b>\n\n"
|
||||
f"Не удалось восстановить клавиатуру из сохраненных данных.\n"
|
||||
f"Ошибка: {str(e)}\n\n"
|
||||
f"Пожалуйста, создайте рассылку заново.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
|
||||
|
||||
if not tg_ids:
|
||||
await callback_query.message.edit_text(
|
||||
"⚠️ Не найдено получателей для рассылки.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
|
||||
|
||||
messages = []
|
||||
@@ -353,23 +177,33 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
|
||||
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
|
||||
messages.append(message_data)
|
||||
|
||||
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15, session=session)
|
||||
success_count = sum(1 for result in results if result)
|
||||
broadcast_service = BroadcastService(bot=callback_query.bot, session=session, messages_per_second=35)
|
||||
|
||||
stats = await broadcast_service.broadcast(messages, workers=5)
|
||||
|
||||
duration_minutes = int(stats["total_duration"] // 60)
|
||||
duration_seconds = int(stats["total_duration"] % 60)
|
||||
duration_str = (
|
||||
f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=(
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
f"✅ <b>Доставлено:</b> {stats['success_count']}\n"
|
||||
f"❌ <b>Не доставлено:</b> {stats['failed_count']}\n"
|
||||
f"🚫 <b>Заблокировавших бота:</b> {stats['blocked_users']}\n\n"
|
||||
f"⏱️ <b>Время выполнения:</b> {duration_str}\n"
|
||||
f"⚡ <b>Средняя скорость:</b> {stats['avg_speed']:.1f} сообщений/сек"
|
||||
),
|
||||
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):
|
||||
@router.callback_query(F.data == "cancel_broadcast", IsAdminFilter())
|
||||
async def handle_broadcast_cancel(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
"🚫 Рассылка отменена.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
class BroadcastMessage:
|
||||
def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None) -> None:
|
||||
self.tg_id = tg_id
|
||||
self.text = text
|
||||
self.photo = photo
|
||||
self.keyboard = keyboard
|
||||
self.retry_after = None
|
||||
self.attempts = 0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
|
||||
self.max_rate = max_rate
|
||||
self.window = window
|
||||
self.send_times = deque()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
def _clean_old_timestamps(self, current_time: float):
|
||||
cutoff_time = current_time - self.window
|
||||
while self.send_times and self.send_times[0] <= cutoff_time:
|
||||
self.send_times.popleft()
|
||||
|
||||
async def acquire(self):
|
||||
async with self.lock:
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
self._clean_old_timestamps(now)
|
||||
|
||||
if len(self.send_times) < self.max_rate:
|
||||
self.send_times.append(now)
|
||||
return
|
||||
|
||||
oldest_timestamp = self.send_times[0]
|
||||
time_to_wait = (oldest_timestamp + self.window) - now
|
||||
|
||||
if time_to_wait > 0:
|
||||
await asyncio.sleep(time_to_wait + 0.001)
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
|
||||
self.bot = bot
|
||||
self.session = session
|
||||
self.rate_limiter = RateLimiter(max_rate=messages_per_second)
|
||||
self.blocked_users = set()
|
||||
self.queue = asyncio.Queue()
|
||||
self.delayed_queue = asyncio.Queue()
|
||||
self.results = []
|
||||
self.total_sent = 0
|
||||
self.start_time = None
|
||||
self.is_running = False
|
||||
|
||||
async def _send_single_message(self, msg: BroadcastMessage) -> bool:
|
||||
try:
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
if msg.photo:
|
||||
await self.bot.send_photo(
|
||||
chat_id=msg.tg_id, photo=msg.photo, caption=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
|
||||
)
|
||||
else:
|
||||
await self.bot.send_message(
|
||||
chat_id=msg.tg_id, text=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
msg.retry_after = e.retry_after
|
||||
msg.attempts += 1
|
||||
logger.warning(
|
||||
f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. (попытка {msg.attempts})"
|
||||
)
|
||||
await self.delayed_queue.put(msg)
|
||||
return False
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {msg.tg_id}")
|
||||
self.blocked_users.add(msg.tg_id)
|
||||
return False
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {msg.tg_id}")
|
||||
self.blocked_users.add(msg.tg_id)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {msg.tg_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {msg.tg_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _process_delayed_messages(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
if not self.delayed_queue.empty():
|
||||
msg = await asyncio.wait_for(self.delayed_queue.get(), timeout=0.1)
|
||||
|
||||
if msg.retry_after:
|
||||
await asyncio.sleep(msg.retry_after)
|
||||
msg.retry_after = None
|
||||
|
||||
if msg.attempts < 3:
|
||||
await self.queue.put(msg)
|
||||
else:
|
||||
logger.error(f"❌ Достигнут лимит попыток для {msg.tg_id}")
|
||||
self.results.append(False)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _worker(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self.queue.get(), timeout=0.1)
|
||||
|
||||
success = await self._send_single_message(msg)
|
||||
|
||||
if success:
|
||||
self.total_sent += 1
|
||||
self.results.append(True)
|
||||
elif msg.attempts == 0:
|
||||
self.results.append(False)
|
||||
|
||||
self.queue.task_done()
|
||||
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в воркере рассылки: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _save_blocked_users(self):
|
||||
if not self.blocked_users:
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from database.models import BlockedUser
|
||||
|
||||
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
|
||||
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
|
||||
await self.session.execute(stmt)
|
||||
await self.session.commit()
|
||||
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
|
||||
await self.session.rollback()
|
||||
|
||||
async def broadcast(self, messages: list[dict], workers: int = 20) -> dict:
|
||||
self.is_running = True
|
||||
self.start_time = time.time()
|
||||
self.results = []
|
||||
self.total_sent = 0
|
||||
self.blocked_users = set()
|
||||
|
||||
for msg_data in messages:
|
||||
msg = BroadcastMessage(
|
||||
tg_id=msg_data["tg_id"],
|
||||
text=msg_data["text"],
|
||||
photo=msg_data.get("photo"),
|
||||
keyboard=msg_data.get("keyboard"),
|
||||
)
|
||||
await self.queue.put(msg)
|
||||
|
||||
logger.info(f"📤 Начата рассылка на {len(messages)} пользователей с {workers} воркерами")
|
||||
|
||||
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(workers)]
|
||||
|
||||
delayed_task = asyncio.create_task(self._process_delayed_messages())
|
||||
|
||||
await self.queue.join()
|
||||
|
||||
await asyncio.sleep(1)
|
||||
while not self.delayed_queue.empty():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
self.is_running = False
|
||||
|
||||
for task in worker_tasks:
|
||||
task.cancel()
|
||||
delayed_task.cancel()
|
||||
|
||||
await asyncio.gather(*worker_tasks, delayed_task, return_exceptions=True)
|
||||
|
||||
await self._save_blocked_users()
|
||||
|
||||
end_time = time.time()
|
||||
total_duration = end_time - self.start_time
|
||||
success_count = sum(1 for r in self.results if r)
|
||||
avg_speed = self.total_sent / total_duration if total_duration > 0 else 0
|
||||
|
||||
stats = {
|
||||
"total_duration": total_duration,
|
||||
"total_sent": self.total_sent,
|
||||
"success_count": success_count,
|
||||
"failed_count": len(self.results) - success_count,
|
||||
"avg_speed": avg_speed,
|
||||
"total_messages": len(messages),
|
||||
"blocked_users": len(self.blocked_users),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"✅ Рассылка завершена: {success_count}/{len(messages)} успешно, "
|
||||
f"скорость: {avg_speed:.1f} сообщений/сек, время: {total_duration:.1f} сек"
|
||||
)
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,6 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
preview = State()
|
||||
@@ -0,0 +1,146 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import distinct, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]:
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
banned_tg_ids = select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
|
||||
)
|
||||
|
||||
query = None
|
||||
|
||||
if send_to == "subscribed":
|
||||
query = (
|
||||
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
elif send_to == "unsubscribed":
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
.outerjoin(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.count(Key.tg_id) == 0)
|
||||
.union_all(
|
||||
select(User.tg_id)
|
||||
.join(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.max(Key.expiry_time) <= now_ms)
|
||||
)
|
||||
)
|
||||
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
|
||||
|
||||
elif send_to == "untrial":
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
elif send_to == "cluster":
|
||||
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)
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
elif send_to == "hotleads":
|
||||
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Payment, User.tg_id == Payment.tg_id)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
elif send_to == "trial":
|
||||
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
query = (
|
||||
select(distinct(Key.tg_id))
|
||||
.where(Key.tariff_id.in_(trial_tariff_subquery))
|
||||
.where(~Key.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
else:
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
|
||||
|
||||
result = await session.execute(query)
|
||||
tg_ids = [row[0] for row in result.all()]
|
||||
return tg_ids, len(tg_ids)
|
||||
|
||||
|
||||
def strip_html_tags(text: str) -> str:
|
||||
text = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", text)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
return text.strip()
|
||||
|
||||
|
||||
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"[Sender] Неверный формат кнопки: {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"[Sender] 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"[Sender] Кнопка без действия: {line}")
|
||||
continue
|
||||
|
||||
buttons.append([button])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[Sender] Ошибка парсинга JSON кнопки: {line} - {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"[Sender] Ошибка создания кнопки: {line} - {e}")
|
||||
continue
|
||||
|
||||
if not buttons:
|
||||
return clean_text, None
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
return clean_text, keyboard
|
||||
@@ -1,3 +1,5 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .servers_handler import router
|
||||
from . import (
|
||||
server_manage, # noqa: F401
|
||||
server_wizard, # noqa: F401
|
||||
)
|
||||
from .server_states import router
|
||||
|
||||
@@ -37,7 +37,7 @@ def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🔙 Назад",
|
||||
text=BACK,
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
|
||||
@@ -70,9 +70,7 @@ def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKe
|
||||
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()
|
||||
)
|
||||
InlineKeyboardButton(text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack())
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -83,7 +81,7 @@ def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
|
||||
[InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}")],
|
||||
[InlineKeyboardButton(text=BACK, callback_data=f"edit_server|{server_name}")],
|
||||
]
|
||||
)
|
||||
|
||||
@@ -94,7 +92,7 @@ def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineK
|
||||
for cluster in clusters:
|
||||
builder.row(InlineKeyboardButton(text=cluster, callback_data=f"set_cluster|{server_name}|{cluster}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"edit_server|{server_name}"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
+53
-185
@@ -1,44 +1,18 @@
|
||||
from aiogram import F, Router, types
|
||||
from aiogram import F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_servers
|
||||
from database.models import Key, Server
|
||||
from database.servers import (
|
||||
get_available_clusters,
|
||||
get_server_by_name,
|
||||
update_server_cluster,
|
||||
update_server_field,
|
||||
update_server_name_with_keys,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_cancel_edit_kb,
|
||||
build_cluster_selection_kb,
|
||||
build_edit_server_fields_kb,
|
||||
build_manage_server_kb,
|
||||
build_panel_type_selection_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
class ServerEditState(StatesGroup):
|
||||
choosing_field = State()
|
||||
editing_value = State()
|
||||
from .keyboard import AdminServerCallback, build_manage_server_kb
|
||||
from .server_states import ServerLimitState, router
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
@@ -102,11 +76,19 @@ async def process_callback_delete_server(
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
from ..clusters.clusters_handler import AdminClusterStates
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
from database import get_servers as get_servers_inner
|
||||
from database.models import (
|
||||
Key as KeyModel,
|
||||
Server as ServerModel,
|
||||
)
|
||||
|
||||
from ..clusters.base import AdminClusterStates
|
||||
|
||||
server_name = callback_data.data
|
||||
|
||||
servers_dict = await get_servers(session, include_enabled=True)
|
||||
servers_dict = await get_servers_inner(session, include_enabled=True)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers_dict.items():
|
||||
if any(s["server_name"] == server_name for s in server_list):
|
||||
@@ -120,16 +102,23 @@ async def process_callback_delete_server(
|
||||
)
|
||||
return
|
||||
|
||||
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
|
||||
stmt_keys_count = select(func.count()).where(KeyModel.server_id == server_name)
|
||||
result = await session.execute(stmt_keys_count)
|
||||
keys_count = result.scalar_one()
|
||||
|
||||
if keys_count > 0:
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
|
||||
subq = select(func.count()).where(Key.server_id == Server.server_name).correlate(Server).scalar_subquery()
|
||||
subq = (
|
||||
select(func.count())
|
||||
.where(KeyModel.server_id == ServerModel.server_name)
|
||||
.correlate(ServerModel)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(Server.server_name != server_name)
|
||||
stmt_all_servers = select(ServerModel.server_name, subq.label("key_count")).where(
|
||||
ServerModel.server_name != server_name
|
||||
)
|
||||
result = await session.execute(stmt_all_servers)
|
||||
all_servers = result.all()
|
||||
|
||||
@@ -139,20 +128,22 @@ async def process_callback_delete_server(
|
||||
callback_data = f"transfer_to_server|{s_name}|{server_name}"
|
||||
if len(callback_data.encode("utf-8")) > 64:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
||||
text=(
|
||||
f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{s_name} ({key_count})",
|
||||
callback_data=callback_data,
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
@@ -182,6 +173,8 @@ async def process_callback_delete_server(
|
||||
cluster_keys_count = result.scalar_one()
|
||||
|
||||
if cluster_keys_count > 0:
|
||||
from ..clusters.base import AdminClusterStates
|
||||
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
|
||||
subq_cluster = (
|
||||
@@ -201,41 +194,53 @@ async def process_callback_delete_server(
|
||||
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
|
||||
if len(callback_data.encode("utf-8")) > 64:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
|
||||
text=(
|
||||
f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
|
||||
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{cl_name} ({key_count})",
|
||||
callback_data=callback_data,
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
types.InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
|
||||
text=(
|
||||
f"⚠️ Это последний сервер в кластере '{cluster_name}'. "
|
||||
f"На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:"
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||
return
|
||||
|
||||
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
|
||||
stmt_delete = sa_delete(Server).where(
|
||||
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||
)
|
||||
await session.execute(stmt_delete)
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
|
||||
text=(
|
||||
f"✅ Сервер '{server_name}' удален. "
|
||||
f"Кластер '{cluster_name}' также удален, так как в нем не осталось серверов."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
else:
|
||||
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
|
||||
stmt_delete = sa_delete(Server).where(
|
||||
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||
)
|
||||
await session.execute(stmt_delete)
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
@@ -341,140 +346,3 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
|
||||
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await state.clear()
|
||||
|
||||
server_data = await get_server_by_name(session, server_name)
|
||||
if not server_data:
|
||||
await callback.message.edit_text("❌ Сервер не найден.")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
|
||||
reply_markup=build_edit_server_fields_kb(server_name, server_data),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
|
||||
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, server_name, field = callback.data.split("|")
|
||||
|
||||
if field == "cluster_name":
|
||||
clusters = await get_available_clusters(session)
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
|
||||
reply_markup=build_cluster_selection_kb(server_name, clusters),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name, field=field)
|
||||
await state.set_state(ServerEditState.editing_value)
|
||||
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID/Squads",
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||
reply_markup=build_cancel_edit_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
|
||||
async def select_panel_type(callback: CallbackQuery):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
|
||||
reply_markup=build_panel_type_selection_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
|
||||
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, panel_type = callback.data.split("|")
|
||||
|
||||
success = await update_server_field(session, server_name, "panel_type", panel_type)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
|
||||
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, new_cluster = callback.data.split("|")
|
||||
|
||||
success = await update_server_cluster(session, server_name, new_cluster)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении кластера")
|
||||
|
||||
|
||||
@router.message(ServerEditState.editing_value, IsAdminFilter())
|
||||
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
server_name = data["server_name"]
|
||||
field = data["field"]
|
||||
value = message.text.strip()
|
||||
|
||||
if field == "server_name":
|
||||
if len(value) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
success = await update_server_name_with_keys(session, server_name, value)
|
||||
if success:
|
||||
server_name = value
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
|
||||
return
|
||||
else:
|
||||
success = await update_server_field(session, server_name, field, value)
|
||||
|
||||
if success:
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID/Squads",
|
||||
}
|
||||
|
||||
await message.answer(
|
||||
f"✅ {field_names.get(field, field).capitalize()} изменено",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении поля")
|
||||
|
||||
await state.clear()
|
||||
@@ -0,0 +1,14 @@
|
||||
from aiogram import Router
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
class ServerEditState(StatesGroup):
|
||||
choosing_field = State()
|
||||
editing_value = State()
|
||||
@@ -0,0 +1,164 @@
|
||||
from aiogram import F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.servers import (
|
||||
get_available_clusters,
|
||||
get_server_by_name,
|
||||
update_server_cluster,
|
||||
update_server_field,
|
||||
update_server_name_with_keys,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_cancel_edit_kb,
|
||||
build_cluster_selection_kb,
|
||||
build_edit_server_fields_kb,
|
||||
build_panel_type_selection_kb,
|
||||
)
|
||||
from .server_states import ServerEditState, router
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
|
||||
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await state.clear()
|
||||
|
||||
server_data = await get_server_by_name(session, server_name)
|
||||
if not server_data:
|
||||
await callback.message.edit_text("❌ Сервер не найден.")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
|
||||
reply_markup=build_edit_server_fields_kb(server_name, server_data),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
|
||||
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
_, server_name, field = callback.data.split("|")
|
||||
|
||||
if field == "cluster_name":
|
||||
clusters = await get_available_clusters(session)
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
|
||||
reply_markup=build_cluster_selection_kb(server_name, clusters),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name, field=field)
|
||||
await state.set_state(ServerEditState.editing_value)
|
||||
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID/Squads",
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||
reply_markup=build_cancel_edit_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
|
||||
async def select_panel_type(callback: CallbackQuery):
|
||||
server_name = callback.data.split("|")[1]
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
|
||||
reply_markup=build_panel_type_selection_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
|
||||
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, panel_type = callback.data.split("|")
|
||||
|
||||
success = await update_server_field(session, server_name, "panel_type", panel_type)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу",
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
|
||||
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
|
||||
_, server_name, new_cluster = callback.data.split("|")
|
||||
|
||||
success = await update_server_cluster(session, server_name, new_cluster)
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу",
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text("❌ Ошибка при изменении кластера")
|
||||
|
||||
|
||||
@router.message(ServerEditState.editing_value, IsAdminFilter())
|
||||
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
server_name = data["server_name"]
|
||||
field = data["field"]
|
||||
value = message.text.strip()
|
||||
|
||||
if field == "server_name":
|
||||
if len(value) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
success = await update_server_name_with_keys(session, server_name, value)
|
||||
if success:
|
||||
server_name = value
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
|
||||
return
|
||||
else:
|
||||
success = await update_server_field(session, server_name, field, value)
|
||||
|
||||
if success:
|
||||
field_names = {
|
||||
"server_name": "имя сервера",
|
||||
"api_url": "API URL",
|
||||
"subscription_url": "Subscription URL",
|
||||
"inbound_id": "Inbound ID/Squads",
|
||||
}
|
||||
|
||||
await message.answer(
|
||||
f"✅ {field_names.get(field, field).capitalize()} изменено",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.button(
|
||||
text="⬅️ Назад к серверу",
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
|
||||
)
|
||||
.as_markup(),
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при изменении поля")
|
||||
|
||||
await state.clear()
|
||||
@@ -0,0 +1,23 @@
|
||||
from aiogram import Router
|
||||
|
||||
from filters.admin import IsSuperAdminFilter
|
||||
|
||||
from .settings_buttons import router as settings_buttons_router
|
||||
from .settings_cashboxes import router as settings_cashboxes_router
|
||||
from .settings_manage import router as settings_manage_router
|
||||
from .settings_modes import router as settings_modes_router
|
||||
from .settings_money import router as settings_panels_router
|
||||
from .settings_notifications import router as settings_notifications_router
|
||||
from .settings_tariffs import router as settings_tariffs_router
|
||||
|
||||
|
||||
router = Router(name="admin_settings")
|
||||
router.callback_query.filter(IsSuperAdminFilter())
|
||||
router.message.filter(IsSuperAdminFilter())
|
||||
router.include_router(settings_manage_router)
|
||||
router.include_router(settings_buttons_router)
|
||||
router.include_router(settings_cashboxes_router)
|
||||
router.include_router(settings_panels_router)
|
||||
router.include_router(settings_notifications_router)
|
||||
router.include_router(settings_modes_router)
|
||||
router.include_router(settings_tariffs_router)
|
||||
@@ -0,0 +1,272 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from core.settings.money_config import get_currency_mode
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
from .settings_config import (
|
||||
BUTTON_TITLES,
|
||||
MODES_TITLES,
|
||||
MONEY_FIELDS,
|
||||
NOTIFICATION_TIME_FIELDS,
|
||||
NOTIFICATION_TITLES,
|
||||
PAYMENT_PROVIDER_TITLES,
|
||||
)
|
||||
|
||||
|
||||
def build_toggle_section_keyboard(
|
||||
titles: dict[str, str],
|
||||
state: dict[str, bool],
|
||||
action: str,
|
||||
columns: int,
|
||||
back_action: str = "settings",
|
||||
extra_rows: list[list[InlineKeyboardButton]] | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for index, key in enumerate(titles.keys(), start=1):
|
||||
title = titles[key]
|
||||
current_state = bool(state.get(key, False))
|
||||
prefix = "✅" if current_state else "❌"
|
||||
builder.button(
|
||||
text=f"{prefix} {title}",
|
||||
callback_data=AdminPanelCallback(
|
||||
action=action,
|
||||
page=index,
|
||||
).pack(),
|
||||
)
|
||||
|
||||
builder.adjust(columns)
|
||||
|
||||
if extra_rows:
|
||||
for row in extra_rows:
|
||||
builder.row(*row)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action=back_action).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_settings_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="Кассы",
|
||||
callback_data=AdminPanelCallback(action="settings_cashboxes").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Деньги",
|
||||
callback_data=AdminPanelCallback(action="settings_money").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Кнопки",
|
||||
callback_data=AdminPanelCallback(action="settings_buttons").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Уведомления",
|
||||
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Режимы",
|
||||
callback_data=AdminPanelCallback(action="settings_modes").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Тарификация",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(2, 2, 2)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_settings_buttons_kb(buttons_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||
return build_toggle_section_keyboard(
|
||||
titles=BUTTON_TITLES,
|
||||
state=buttons_state,
|
||||
action="settings_button_toggle",
|
||||
columns=2,
|
||||
back_action="settings",
|
||||
)
|
||||
|
||||
|
||||
def build_settings_cashboxes_kb(providers_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||
order_button = InlineKeyboardButton(
|
||||
text="📋 Порядок касс",
|
||||
callback_data=AdminPanelCallback(action="settings_providers_order").pack(),
|
||||
)
|
||||
return build_toggle_section_keyboard(
|
||||
titles=PAYMENT_PROVIDER_TITLES,
|
||||
state=providers_state,
|
||||
action="settings_cashbox_toggle",
|
||||
columns=2,
|
||||
back_action="settings",
|
||||
extra_rows=[[order_button]],
|
||||
)
|
||||
|
||||
|
||||
def build_providers_order_kb(sorted_names: list[str]) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для управления порядком отображения касс."""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for idx, name in enumerate(sorted_names):
|
||||
title = PAYMENT_PROVIDER_TITLES.get(name, name)
|
||||
pos = idx + 1
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬆️",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_order_up",
|
||||
page=pos,
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"{pos}. {title}",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_providers_order",
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⬇️",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_order_down",
|
||||
page=pos,
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Сбросить порядок",
|
||||
callback_data=AdminPanelCallback(action="settings_order_reset").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="settings_cashboxes").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_settings_notifications_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||
intervals_button = InlineKeyboardButton(
|
||||
text="Интервалы",
|
||||
callback_data=AdminPanelCallback(action="settings_notifications_intervals").pack(),
|
||||
)
|
||||
|
||||
return build_toggle_section_keyboard(
|
||||
titles=NOTIFICATION_TITLES,
|
||||
state={k: bool(notifications_state.get(k, False)) for k in NOTIFICATION_TITLES},
|
||||
action="settings_notification_toggle",
|
||||
columns=1,
|
||||
back_action="settings",
|
||||
extra_rows=[[intervals_button]],
|
||||
)
|
||||
|
||||
|
||||
def build_settings_notifications_intervals_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
keys = list(NOTIFICATION_TIME_FIELDS.keys())
|
||||
for index, key in enumerate(keys, start=1):
|
||||
title = NOTIFICATION_TIME_FIELDS[key]
|
||||
value = notifications_state.get(key)
|
||||
value_text = "не задано" if value is None else str(value)
|
||||
|
||||
builder.button(
|
||||
text=f"{title}: {value_text}",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_notification_interval_edit",
|
||||
page=index,
|
||||
).pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_settings_modes_kb(modes_state: dict[str, bool]) -> InlineKeyboardMarkup:
|
||||
return build_toggle_section_keyboard(
|
||||
titles=MODES_TITLES,
|
||||
state=modes_state,
|
||||
action="settings_modes_toggle",
|
||||
columns=2,
|
||||
back_action="settings",
|
||||
)
|
||||
|
||||
|
||||
def build_settings_money_kb(money_state: dict[str, object]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
field_keys = list(MONEY_FIELDS.keys())
|
||||
for index, key in enumerate(field_keys, start=1):
|
||||
title = MONEY_FIELDS[key]
|
||||
value = money_state.get(key)
|
||||
|
||||
if key == "RUB_TO_USD":
|
||||
if value is False or value is None:
|
||||
value_text = "по ЦБ РФ"
|
||||
else:
|
||||
value_text = str(value)
|
||||
elif key == "CASHBACK":
|
||||
try:
|
||||
numeric_value = float(value) if value not in (None, False) else 0.0
|
||||
except (TypeError, ValueError):
|
||||
numeric_value = 0.0
|
||||
if numeric_value <= 0:
|
||||
value_text = "выкл"
|
||||
else:
|
||||
value_text = f"{numeric_value:g} %"
|
||||
else:
|
||||
value_text = "не задано" if value is None else str(value)
|
||||
|
||||
builder.button(
|
||||
text=f"{title}: {value_text}",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_money_edit",
|
||||
page=index,
|
||||
).pack(),
|
||||
)
|
||||
|
||||
mode, one_screen = get_currency_mode()
|
||||
if mode == "RUB+USD" and one_screen:
|
||||
mode_text = "RUB+USD (одним экраном)"
|
||||
else:
|
||||
mode_text = mode
|
||||
|
||||
builder.button(
|
||||
text=f"Режим валют: {mode_text}",
|
||||
callback_data=AdminPanelCallback(
|
||||
action="settings_money_currency",
|
||||
page=0,
|
||||
).pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,53 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.bootstrap import BUTTONS_CONFIG, update_buttons_config
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import BUTTON_TITLES, build_settings_buttons_kb
|
||||
|
||||
|
||||
router = Router(name="admin_settings_buttons")
|
||||
router.callback_query.filter(IsAdminFilter())
|
||||
|
||||
|
||||
async def load_button_settings() -> dict[str, bool]:
|
||||
config = BUTTONS_CONFIG or {}
|
||||
return {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_buttons"))
|
||||
async def open_settings_buttons_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
buttons_state = await load_button_settings()
|
||||
text = "Здесь вы можете включать или отключать кнопки в меню бота."
|
||||
await callback.message.edit_text(text=text, reply_markup=build_settings_buttons_kb(buttons_state))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_button_toggle"))
|
||||
async def toggle_button_setting(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminPanelCallback,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
keys = list(BUTTON_TITLES.keys())
|
||||
idx = callback_data.page
|
||||
|
||||
if not 1 <= idx <= len(keys):
|
||||
await callback.answer("Неизвестная настройка", show_alert=True)
|
||||
return
|
||||
|
||||
key = keys[idx - 1]
|
||||
|
||||
config = dict(BUTTONS_CONFIG or {})
|
||||
current = bool(config.get(key, False))
|
||||
config[key] = not current
|
||||
|
||||
await update_buttons_config(session, config)
|
||||
await session.commit()
|
||||
|
||||
buttons_state = {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
|
||||
await callback.message.edit_reply_markup(reply_markup=build_settings_buttons_kb(buttons_state))
|
||||
await callback.answer("Настройка обновлена")
|
||||
@@ -0,0 +1,152 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.bootstrap import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.payments.providers import PROVIDERS_BASE, _get_effective_order
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import PAYMENT_PROVIDER_TITLES, build_providers_order_kb, build_settings_cashboxes_kb
|
||||
|
||||
|
||||
router = Router(name="admin_settings_cashboxes")
|
||||
router.callback_query.filter(IsAdminFilter())
|
||||
|
||||
|
||||
async def load_payment_providers_settings() -> dict[str, bool]:
|
||||
config = PAYMENTS_CONFIG or {}
|
||||
return {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
|
||||
|
||||
|
||||
def _get_sorted_provider_names() -> list[str]:
|
||||
"""Возвращает все провайдеры, отсортированные по текущему порядку."""
|
||||
all_names = list(PROVIDERS_BASE.keys())
|
||||
return sorted(
|
||||
all_names,
|
||||
key=lambda n: _get_effective_order(n, PROVIDERS_BASE.get(n, {})),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashboxes"))
|
||||
async def open_settings_cashboxes_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
providers_state = await load_payment_providers_settings()
|
||||
text = "Здесь можно включать и отключать платёжные провайдеры."
|
||||
await callback.message.edit_text(text=text, reply_markup=build_settings_cashboxes_kb(providers_state))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashbox_toggle"))
|
||||
async def toggle_cashbox_setting(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminPanelCallback,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
keys = list(PAYMENT_PROVIDER_TITLES.keys())
|
||||
index = callback_data.page
|
||||
|
||||
if not 1 <= index <= len(keys):
|
||||
await callback.answer("Неизвестная касса", show_alert=True)
|
||||
return
|
||||
|
||||
provider_code = keys[index - 1]
|
||||
|
||||
config = dict(PAYMENTS_CONFIG or {})
|
||||
current_value = bool(config.get(provider_code, False))
|
||||
config[provider_code] = not current_value
|
||||
|
||||
await update_payments_config(
|
||||
session,
|
||||
config,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated_state = {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=build_settings_cashboxes_kb(updated_state),
|
||||
)
|
||||
await callback.answer("Настройка обновлена")
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_providers_order"))
|
||||
async def open_providers_order_menu(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
sorted_names = _get_sorted_provider_names()
|
||||
text = (
|
||||
"📋 <b>Порядок отображения касс</b>\n\n"
|
||||
"⬆️ — поднять выше\n"
|
||||
"⬇️ — опустить ниже\n\n"
|
||||
"Порядок влияет на меню оплаты и fast flow."
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_providers_order_kb(sorted_names),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_up"))
|
||||
async def move_provider_up(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminPanelCallback,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
sorted_names = _get_sorted_provider_names()
|
||||
idx = callback_data.page - 1
|
||||
|
||||
if idx <= 0:
|
||||
await callback.answer("Уже на первом месте", show_alert=False)
|
||||
return
|
||||
|
||||
if idx >= len(sorted_names):
|
||||
await callback.answer("Неизвестная касса", show_alert=True)
|
||||
return
|
||||
|
||||
sorted_names[idx], sorted_names[idx - 1] = sorted_names[idx - 1], sorted_names[idx]
|
||||
|
||||
new_order = {name: (i + 1) * 10 for i, name in enumerate(sorted_names)}
|
||||
await update_providers_order(session, new_order)
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=build_providers_order_kb(sorted_names),
|
||||
)
|
||||
await callback.answer("✅ Перемещено выше")
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_down"))
|
||||
async def move_provider_down(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminPanelCallback,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
sorted_names = _get_sorted_provider_names()
|
||||
idx = callback_data.page - 1
|
||||
|
||||
if idx >= len(sorted_names) - 1:
|
||||
await callback.answer("Уже на последнем месте", show_alert=False)
|
||||
return
|
||||
|
||||
if idx < 0:
|
||||
await callback.answer("Неизвестная касса", show_alert=True)
|
||||
return
|
||||
|
||||
sorted_names[idx], sorted_names[idx + 1] = sorted_names[idx + 1], sorted_names[idx]
|
||||
|
||||
new_order = {name: (i + 1) * 10 for i, name in enumerate(sorted_names)}
|
||||
await update_providers_order(session, new_order)
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=build_providers_order_kb(sorted_names),
|
||||
)
|
||||
await callback.answer("✅ Перемещено ниже")
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_reset"))
|
||||
async def reset_providers_order(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
await update_providers_order(session, {})
|
||||
|
||||
sorted_names = _get_sorted_provider_names()
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=build_providers_order_kb(sorted_names),
|
||||
)
|
||||
await callback.answer("✅ Порядок сброшен на дефолтный")
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Final
|
||||
|
||||
|
||||
BUTTON_TITLES: Final[dict[str, str]] = {
|
||||
"CHANNEL_BUTTON_ENABLE": "Канал",
|
||||
"DONATIONS_BUTTON_ENABLE": "Донаты",
|
||||
"BALANCE_BUTTON_ENABLE": "Баланс",
|
||||
"REFERRAL_QR_BUTTON_ENABLE": "QR реф.меню",
|
||||
"DELETE_KEY_BUTTON_ENABLE": "Удалить подп-ку",
|
||||
"INSTRUCTIONS_BUTTON_ENABLE": "Инструкции",
|
||||
"TOGGLE_CLIENT_BUTTON_ENABLE": "Заморозка подписки",
|
||||
"GIFT_BUTTON_ENABLE": "Подарки",
|
||||
"REFERRAL_BUTTON_ENABLE": "Реф.система",
|
||||
"TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов",
|
||||
"QRCODE_BUTTON_ENABLE": "QR подписки",
|
||||
"HWID_RESET_BUTTON_ENABLE": "Сброс HWID",
|
||||
"ANDROID_TV_BUTTON_ENABLE": "Android TV",
|
||||
"COUPON_BUTTON_ENABLE": "Активировать купон",
|
||||
}
|
||||
|
||||
NOTIFICATION_TITLES: Final[dict[str, str]] = {
|
||||
"RENEW_ENABLED": "Авто-продление",
|
||||
"EXPIRY_24H_ENABLED": "За 24 часа",
|
||||
"EXPIRY_10H_ENABLED": "За 10 часов",
|
||||
"DELETE_KEY_ENABLED": "Удалять просроченные",
|
||||
"RENEW_EXPIRED_ENABLED": "Продлевать просроченные",
|
||||
"HOT_LEADS_ENABLED": "Горячие лиды",
|
||||
}
|
||||
|
||||
NOTIFICATION_TIME_FIELDS: Final[dict[str, str]] = {
|
||||
"BASE_NOTIFICATION_MINUTE": "Проверка (сек)",
|
||||
"INACTIVE_USER_ENABLED": "Неактивные (ч)",
|
||||
"EXPIRY_24H_BEFORE_HOURS": "До 24ч (ч)",
|
||||
"EXPIRY_10H_BEFORE_HOURS": "До 10ч (ч)",
|
||||
"DELETE_KEY_DELAY_MINUTES": "Удаление (мин)",
|
||||
"EXTRA_DAYS_AFTER_EXPIRY": "Дни к пробнику",
|
||||
"INACTIVE_TRAFFIC_ENABLED": "Трафик неакт. (ч)",
|
||||
"HOT_LEADS_INTERVAL_HOURS": "Гор.лиды (ч)",
|
||||
"DISCOUNT_ACTIVE_HOURS": "Скидка (ч)",
|
||||
}
|
||||
|
||||
PAYMENT_PROVIDER_TITLES: Final[dict[str, str]] = {
|
||||
"YOOKASSA": "YooKassa",
|
||||
"YOOMONEY": "YooMoney",
|
||||
"ROBOKASSA": "Robokassa",
|
||||
"KASSAI_CARDS": "KassaAI карты",
|
||||
"KASSAI_SBP": "KassaAI СБП",
|
||||
"TRIBUTE": "Tribute",
|
||||
"HELEKET": "Heleket",
|
||||
"CRYPTOBOT": "CryptoBot",
|
||||
"FREEKASSA": "FreeKassa",
|
||||
"STARS": "Telegram Stars",
|
||||
}
|
||||
|
||||
MODES_TITLES: Final[dict[str, str]] = {
|
||||
"CAPTCHA_ENABLED": "Капча",
|
||||
"CHANNEL_CHECK_ENABLED": "Обязат. канал",
|
||||
"SHOW_START_MENU_ONLY_ONCE": "Старт один раз",
|
||||
"INLINE_MODE_ENABLED": "Инлайн-режим",
|
||||
"RANDOM_SUBSCRIPTIONS_ENABLED": "Случайные страны",
|
||||
"COUNTRY_SELECTION_ENABLED": "Режим стран",
|
||||
"REMNAWAVE_WEBAPP_ENABLED": "Remna WebApp",
|
||||
"REMNAWAVE_WEBAPP_OPEN_IN_BROWSER": "WebApp в браузере",
|
||||
"HAPP_CRYPTOLINK_ENABLED": "Happ-ссылки",
|
||||
"LEGACY_LINKS_ENABLED": "Старые ссылки",
|
||||
"DIRECT_START_DISABLED": "Тихий режим",
|
||||
"TRIAL_TIME_DISABLED": "Отключить триал",
|
||||
}
|
||||
|
||||
MONEY_FIELDS: Final[dict[str, str]] = {
|
||||
"FX_MARKUP": "Наценка FX (%)",
|
||||
"RUB_TO_USD": "Курс USD/RUB",
|
||||
"CASHBACK": "Кэшбэк (%)",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import build_settings_kb
|
||||
|
||||
|
||||
router = Router(name="admin_settings_manage")
|
||||
router.callback_query.filter(IsAdminFilter())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings"))
|
||||
async def open_settings_menu(callback: CallbackQuery) -> None:
|
||||
text = (
|
||||
"Здесь вы можете изменить основные настройки бота, не перезагружая его\n"
|
||||
"(Меню будет пополняться)\n\n"
|
||||
"<blockquote>"
|
||||
"⚠️⚠️⚠️ ВАЖНО! Эти настройки являются техническими и не рассчитаны на обычное использование.\n"
|
||||
"Не включайте и не меняйте настройки, если вы не понимаете, что они делают!\n"
|
||||
"Бездумные изменения могут нарушить работу бота или базы данных."
|
||||
"</blockquote>\n\n"
|
||||
"Если вы не уверены, что делает настройка — уточните вопрос в чате."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_settings_kb())
|
||||
await callback.answer()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user