diff --git a/api/depends.py b/api/depends.py index a1873235..b67d668e 100644 --- a/api/depends.py +++ b/api/depends.py @@ -6,7 +6,7 @@ from fastapi import Depends, HTTPException, Header, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from database import async_session_maker +from database import async_session_maker, identities as idb from database.models import Admin @@ -30,3 +30,57 @@ async def verify_admin_token( if not admin: raise HTTPException(status_code=401, detail="Unauthorized") return admin + + +async def verify_identity_token( + x_identity_id: str = Header(..., alias="X-Identity-Id"), + token: str = Header(..., alias="X-Token"), + session: AsyncSession = Depends(get_session), +): + """Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2.""" + identity = await idb.verify_identity_token(session, x_identity_id, token) + if not identity: + raise HTTPException(status_code=401, detail="Unauthorized") + return identity + + +async def verify_identity_admin( + x_identity_id: str = Header(..., alias="X-Identity-Id"), + token: str = Header(..., alias="X-Token"), + session: AsyncSession = Depends(get_session), +): + """Проверяет identity + token и что identity.is_admin; для админских ручек v2.""" + identity = await idb.verify_identity_token(session, x_identity_id, token) + if not identity: + raise HTTPException(status_code=401, detail="Unauthorized") + if not identity.is_admin: + raise HTTPException(status_code=403, detail="Forbidden") + return identity + + +async def verify_identity_admin_short( + x_identity_id: str = Header(..., alias="X-Identity-Id"), + token: str = Header(..., alias="X-Token"), +): + """Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД.""" + async with async_session_maker() as session: + identity = await idb.verify_identity_token(session, x_identity_id, token) + if not identity: + raise HTTPException(status_code=401, detail="Unauthorized") + if not identity.is_admin: + raise HTTPException(status_code=403, detail="Forbidden") + return identity + + +async def verify_admin_token_short( + admin_id: int = Query(..., alias="tg_id"), + token: str = Header(..., alias="X-Token"), +) -> Admin: + """Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД.""" + hashed = hash_token(token) + async with async_session_maker() as session: + result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed)) + admin = result.scalar_one_or_none() + if not admin: + raise HTTPException(status_code=401, detail="Unauthorized") + return admin diff --git a/api/main.py b/api/main.py index 5a0f5633..0ac3d54b 100644 --- a/api/main.py +++ b/api/main.py @@ -1,26 +1,19 @@ 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 config import API_LOGGING, API_VERSION from logger import logger +if API_VERSION == 1: + from api.v1 import router as api_router, VERSION as API_DOC_VERSION +else: + from api.v2 import router as api_router, VERSION as API_DOC_VERSION + app = FastAPI( - title="SoloBot API (Alpha)", - version="0.5.2", + title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}", + version=API_DOC_VERSION, + description=f"Версия API: **v{API_DOC_VERSION}**.", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", @@ -40,25 +33,8 @@ async def api_access_log_middleware(request: Request, call_next): 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' - ) + 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"]) -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) -async def root(): - return {"message": "Welcome to SoloBot API. Docs: /api/docs"} +app.include_router(api_router) diff --git a/api/schemas/management.py b/api/schemas/management.py deleted file mode 100644 index 6ddb3bda..00000000 --- a/api/schemas/management.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class MaintenanceUpdate(BaseModel): - enabled: bool - - -class DomainChange(BaseModel): - domain: str diff --git a/api/v1/__init__.py b/api/v1/__init__.py new file mode 100644 index 00000000..6052f5d8 --- /dev/null +++ b/api/v1/__init__.py @@ -0,0 +1,5 @@ +from api.v1.router import router + +VERSION = "1.0.0" + +__all__ = ("router", "VERSION") diff --git a/api/v1/router.py b/api/v1/router.py new file mode 100644 index 00000000..bf80ef31 --- /dev/null +++ b/api/v1/router.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter + +from api.v1.routes import ( + users, + keys, + coupons, + servers, + tariffs, + gifts, + referrals, + misc, + partners, + modules, + management, + settings, +) + +router = APIRouter() + + +@router.get("/api", include_in_schema=False) +async def root(): + return {"message": "Welcome to SoloBot API", "docs": "/api/docs"} + + +router.include_router(users.router, prefix="/api/users", tags=["Users"]) +router.include_router(keys.router, prefix="/api/keys", tags=["Keys"]) +router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"]) +router.include_router(servers.router, prefix="/api/servers", tags=["Servers"]) +router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"]) +router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"]) +router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"]) +router.include_router(partners.router, prefix="/api/partners", tags=["Partners"]) +router.include_router(misc.router, prefix="/api") +router.include_router(modules.router, prefix="/api") +router.include_router(management.router, prefix="/api/management", tags=["Management"]) +router.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) diff --git a/api/routes/base_crud.py b/api/v1/routes/base_crud.py similarity index 100% rename from api/routes/base_crud.py rename to api/v1/routes/base_crud.py diff --git a/api/routes/coupons.py b/api/v1/routes/coupons.py similarity index 74% rename from api/routes/coupons.py rename to api/v1/routes/coupons.py index 993ac191..6e18a58f 100644 --- a/api/routes/coupons.py +++ b/api/v1/routes/coupons.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from api.routes.base_crud import generate_crud_router -from api.schemas import CouponBase, CouponResponse, CouponUpdate +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate from database.models import Coupon diff --git a/api/routes/gifts.py b/api/v1/routes/gifts.py similarity index 94% rename from api/routes/gifts.py rename to api/v1/routes/gifts.py index 2c155218..5a6aee73 100644 --- a/api/routes/gifts.py +++ b/api/v1/routes/gifts.py @@ -3,8 +3,8 @@ from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import get_session, verify_admin_token -from api.routes.base_crud import generate_crud_router -from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse from database.models import Admin, Gift, GiftUsage diff --git a/api/routes/keys.py b/api/v1/routes/keys.py similarity index 96% rename from api/routes/keys.py rename to api/v1/routes/keys.py index 54614714..befe0160 100644 --- a/api/routes/keys.py +++ b/api/v1/routes/keys.py @@ -5,8 +5,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import get_session, verify_admin_token -from api.routes.base_crud import generate_crud_router -from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate from database.models import Admin, Key, Tariff from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster from logger import logger @@ -80,7 +80,7 @@ async def edit_key_by_email( if not db_key: raise HTTPException(status_code=404, detail="Ключ не найден") - for field, value in key_update.dict(exclude_unset=True).items(): + for field, value in key_update.model_dump(exclude_unset=True).items(): if field == "expiry_time" and value is not None: if isinstance(value, int): ms = value diff --git a/api/routes/management.py b/api/v1/routes/management.py similarity index 90% rename from api/routes/management.py rename to api/v1/routes/management.py index 52bc5a94..b2c15624 100644 --- a/api/routes/management.py +++ b/api/v1/routes/management.py @@ -1,219 +1,221 @@ -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, - } +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, verify_admin_token_short +from database import async_session_maker +from config import API_TOKEN, BOT_SERVICE +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_short), +): + """Запуск рассылки. Сессия БД не держится на время рассылки.""" + 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") + + async with async_session_maker() as session: + 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=None, messages_per_second=rate) + stats = await broadcast_service.broadcast(messages, workers=workers) + return { + "success": True, + "message": "Broadcast completed", + "recipients": total_users, + "stats": stats, + } diff --git a/api/routes/misc.py b/api/v1/routes/misc.py similarity index 98% rename from api/routes/misc.py rename to api/v1/routes/misc.py index a3266840..7ee133b7 100644 --- a/api/routes/misc.py +++ b/api/v1/routes/misc.py @@ -3,8 +3,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import get_session, verify_admin_token -from api.routes.base_crud import generate_crud_router -from api.schemas import ( +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import ( BlockedUserResponse, ManualBanResponse, NotificationResponse, diff --git a/api/routes/modules.py b/api/v1/routes/modules.py similarity index 95% rename from api/routes/modules.py rename to api/v1/routes/modules.py index 13befd21..4836dcb8 100644 --- a/api/routes/modules.py +++ b/api/v1/routes/modules.py @@ -1,124 +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)} +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[3] / "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)} diff --git a/api/routes/partners.py b/api/v1/routes/partners.py similarity index 100% rename from api/routes/partners.py rename to api/v1/routes/partners.py diff --git a/api/routes/referrals.py b/api/v1/routes/referrals.py similarity index 92% rename from api/routes/referrals.py rename to api/v1/routes/referrals.py index 09642c82..4dac1115 100644 --- a/api/routes/referrals.py +++ b/api/v1/routes/referrals.py @@ -3,8 +3,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import get_session, verify_admin_token -from api.routes.base_crud import generate_crud_router -from api.schemas import ReferralResponse +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import ReferralResponse from database.models import Admin, Referral diff --git a/api/routes/servers.py b/api/v1/routes/servers.py similarity index 74% rename from api/routes/servers.py rename to api/v1/routes/servers.py index 92649254..0decede3 100644 --- a/api/routes/servers.py +++ b/api/v1/routes/servers.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from api.routes.base_crud import generate_crud_router -from api.schemas import ServerBase, ServerResponse, ServerUpdate +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate from database.models import Server diff --git a/api/routes/settings.py b/api/v1/routes/settings.py similarity index 86% rename from api/routes/settings.py rename to api/v1/routes/settings.py index 1562f7d3..b82be909 100644 --- a/api/routes/settings.py +++ b/api/v1/routes/settings.py @@ -1,13 +1,13 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel 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 api.v1.schemas.settings import SettingResponse, SettingUpsert +from database import settings_cache 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 @@ -15,7 +15,8 @@ from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_noti 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 +from database.models import Setting +from database.settings import set_setting router = APIRouter() @@ -26,12 +27,9 @@ class ConfigUpdatePayload(BaseModel): @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() +async def get_all_settings(admin=Depends(verify_admin_token)): + """Список всех настроек (из кэша, без запроса к БД).""" + return settings_cache.get_all() @router.get("/configs") @@ -106,13 +104,9 @@ async def update_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() +async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)): + """Настройка по ключу (из кэша, без запроса к БД).""" + obj = settings_cache.get(key) if not obj: raise HTTPException(status_code=404, detail="Setting not found") return obj @@ -133,6 +127,13 @@ async def upsert_setting( ) await session.commit() await session.refresh(obj) + settings_cache.update( + key, + obj.value, + obj.description, + created_at=getattr(obj, "created_at", None), + updated_at=getattr(obj, "updated_at", None), + ) return obj @@ -148,6 +149,5 @@ async def delete_setting( raise HTTPException(status_code=404, detail="Setting not found") await session.delete(obj) await session.commit() + settings_cache.delete(key) return {"detail": "Setting deleted"} - - diff --git a/api/routes/tariffs.py b/api/v1/routes/tariffs.py similarity index 74% rename from api/routes/tariffs.py rename to api/v1/routes/tariffs.py index b0e3b73b..8ccf13f2 100644 --- a/api/routes/tariffs.py +++ b/api/v1/routes/tariffs.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from api.routes.base_crud import generate_crud_router -from api.schemas import TariffBase, TariffResponse, TariffUpdate +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate from database.models import Tariff diff --git a/api/routes/users.py b/api/v1/routes/users.py similarity index 94% rename from api/routes/users.py rename to api/v1/routes/users.py index befb130e..042a60e0 100644 --- a/api/routes/users.py +++ b/api/v1/routes/users.py @@ -5,8 +5,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import get_session, verify_admin_token -from api.routes.base_crud import generate_crud_router -from api.schemas.users import UserBase, UserResponse, UserUpdate +from api.v1.routes.base_crud import generate_crud_router +from api.v1.schemas.users import UserBase, UserResponse, UserUpdate from database import delete_user_data, get_servers from database.models import Key, User from handlers.keys.operations import delete_key_from_cluster diff --git a/api/schemas/__init__.py b/api/v1/schemas/__init__.py similarity index 100% rename from api/schemas/__init__.py rename to api/v1/schemas/__init__.py diff --git a/api/schemas/coupons.py b/api/v1/schemas/coupons.py similarity index 100% rename from api/schemas/coupons.py rename to api/v1/schemas/coupons.py diff --git a/api/schemas/gifts.py b/api/v1/schemas/gifts.py similarity index 100% rename from api/schemas/gifts.py rename to api/v1/schemas/gifts.py diff --git a/api/schemas/keys.py b/api/v1/schemas/keys.py similarity index 100% rename from api/schemas/keys.py rename to api/v1/schemas/keys.py diff --git a/api/schemas/misc.py b/api/v1/schemas/misc.py similarity index 100% rename from api/schemas/misc.py rename to api/v1/schemas/misc.py diff --git a/api/schemas/referrals.py b/api/v1/schemas/referrals.py similarity index 100% rename from api/schemas/referrals.py rename to api/v1/schemas/referrals.py diff --git a/api/schemas/servers.py b/api/v1/schemas/servers.py similarity index 100% rename from api/schemas/servers.py rename to api/v1/schemas/servers.py diff --git a/api/schemas/settings.py b/api/v1/schemas/settings.py similarity index 100% rename from api/schemas/settings.py rename to api/v1/schemas/settings.py diff --git a/api/schemas/tariffs.py b/api/v1/schemas/tariffs.py similarity index 100% rename from api/schemas/tariffs.py rename to api/v1/schemas/tariffs.py diff --git a/api/schemas/users.py b/api/v1/schemas/users.py similarity index 100% rename from api/schemas/users.py rename to api/v1/schemas/users.py diff --git a/api/v2/__init__.py b/api/v2/__init__.py new file mode 100644 index 00000000..0e439d72 --- /dev/null +++ b/api/v2/__init__.py @@ -0,0 +1,5 @@ +from api.v2.router import router + +VERSION = "2.0.0" + +__all__ = ("router", "VERSION") diff --git a/api/v2/base_crud.py b/api/v2/base_crud.py new file mode 100644 index 00000000..c98cda8d --- /dev/null +++ b/api/v2/base_crud.py @@ -0,0 +1,147 @@ +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 + +from api.depends import get_session, verify_identity_admin +from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema + + +def generate_crud_router( + *, + model: type, + schema_response: type, + schema_create: type, + schema_update: type, + identifier_field: str = "tg_id", + parameter_name: str = "tg_id", + extra_get_by_email: bool = False, + enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"), +) -> APIRouter: + router = APIRouter() + + if "get_all" in enabled_methods: + + @router.get("/", response_model=list[schema_response]) + async def get_all( + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + result = await session.execute(select(model)) + 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: + + @router.get("/by_email", response_model=schema_response) + async def get_by_email( + email: str = Query(...), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + result = await session.execute(select(model).where(model.email == email)) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail="Not found by email") + return to_schema(schema_response, obj) + + if "get_one" in enabled_methods: + + @router.get(f"/{{{parameter_name}}}", response_model=schema_response) + async def get_one( + value: int | str = Path(..., alias=parameter_name), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + field = getattr(model, identifier_field) + casted = cast_identifier_type(field, value) + result = await session.execute(select(model).where(field == casted)) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + return to_schema(schema_response, obj) + + if "get_all_by_field" in enabled_methods: + + @router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response]) + async def get_all_by_field( + value: int | str = Path(..., alias=parameter_name), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + field = getattr(model, identifier_field) + casted = cast_identifier_type(field, value) + result = await session.execute(select(model).where(field == casted)) + objs = result.scalars().all() + if not objs: + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + 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: Any = Body(...), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + 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 to_schema(schema_response, obj) + + if "update" in enabled_methods: + + @router.patch(f"/{{{parameter_name}}}", response_model=schema_response) + async def update( + payload: Any = Body(...), + value: int | str = Path(..., alias=parameter_name), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + field = getattr(model, identifier_field) + casted = cast_identifier_type(field, value) + result = await session.execute(select(model).where(field == casted)) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + 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 to_schema(schema_response, obj) + + if "delete" in enabled_methods: + + @router.delete(f"/{{{parameter_name}}}", response_model=dict) + async def delete( + value: int | str = Path(..., alias=parameter_name), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), + ): + field = getattr(model, identifier_field) + casted = cast_identifier_type(field, value) + result = await session.execute(select(model).where(field == casted)) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + await session.delete(obj) + await session.commit() + return {"detail": f"{model.__name__} deleted"} + + return router + + +__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type") diff --git a/api/v2/router.py b/api/v2/router.py new file mode 100644 index 00000000..3e241076 --- /dev/null +++ b/api/v2/router.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter + +from api.v2.routes import ( + root_router, + auth, + users, + keys, + coupons, + servers, + tariffs, + gifts, + referrals, + misc, + partners, + modules, + management, + settings, + payment_links, + identities, +) + +router = APIRouter() + +router.include_router(root_router) +router.include_router(auth.router, prefix="/api") +router.include_router(users.router, prefix="/api/users", tags=["Users"]) +router.include_router(keys.router, prefix="/api/keys", tags=["Keys"]) +router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"]) +router.include_router(servers.router, prefix="/api/servers", tags=["Servers"]) +router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"]) +router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"]) +router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"]) +router.include_router(partners.router, prefix="/api/partners", tags=["Partners"]) +router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"]) +router.include_router(identities.router, prefix="/api/identities", tags=["Identities"]) +router.include_router(misc.router, prefix="/api") +router.include_router(modules.router, prefix="/api") +router.include_router(management.router, prefix="/api/management", tags=["Management"]) +router.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) diff --git a/api/v2/routes/__init__.py b/api/v2/routes/__init__.py new file mode 100644 index 00000000..31b027c0 --- /dev/null +++ b/api/v2/routes/__init__.py @@ -0,0 +1,3 @@ +from api.v2.routes.root import router as root_router + +__all__ = ("root_router",) diff --git a/api/v2/routes/auth.py b/api/v2/routes/auth.py new file mode 100644 index 00000000..cf6f83cd --- /dev/null +++ b/api/v2/routes/auth.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_token +from api.v2.schemas.identities import ( + IdentityResponse, + LinkTelegramRequest, + LoginRequest, + LoginResponse, + LoginTelegramRequest, + RegisterByEmailRequest, + RegisterResponse, +) +from config import API_TOKEN_TTL_DAYS, API_TOKEN +from database import identities as idb +from utils.telegram_login import verify_telegram_login + +router = APIRouter(prefix="/auth", tags=["Auth"]) +TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн." +TELEGRAM_LOGIN_MAX_AGE = 86400 # 24 часа + + +@router.post("/register", response_model=RegisterResponse) +async def register_by_email( + body: RegisterByEmailRequest, + session: AsyncSession = Depends(get_session), +): + ( + """Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """ + + TOKEN_TTL_HINT + + "." + ) + email = body.email.strip().lower() + if not email: + raise HTTPException(status_code=400, detail="Email обязателен") + if not body.password or len(body.password) < 8: + raise HTTPException(status_code=400, detail="Пароль минимум 8 символов") + existing = await idb.get_identity_by_email(session, email) + if existing: + raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует") + identity, token = await idb.create_identity_with_token(session, email=email, password=body.password) + return RegisterResponse(identity_id=identity.id, token=token) + + +@router.post("/login", response_model=LoginResponse) +async def login( + body: LoginRequest, + session: AsyncSession = Depends(get_session), +): + """Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "." + email = body.email.strip().lower() + if not email: + raise HTTPException(status_code=400, detail="Email обязателен") + result = await idb.login_by_email(session, email, body.password) + if not result: + raise HTTPException(status_code=401, detail="Неверный email или пароль") + identity, token = result + return LoginResponse(identity_id=identity.id, token=token) + + +@router.post("/login-telegram", response_model=LoginResponse) +async def login_telegram( + body: LoginTelegramRequest, + session: AsyncSession = Depends(get_session), +): + ( + """Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """ + + TOKEN_TTL_HINT + + "." + ) + payload = body.model_dump(mode="json") + if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE): + raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram") + identity = await idb.get_or_create_identity_for_tg(session, body.id) + token = await idb.issue_token_for_identity(session, identity) + return LoginResponse(identity_id=identity.id, token=token) + + +@router.post("/link-telegram", response_model=IdentityResponse) +async def link_telegram( + body: LinkTelegramRequest, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + """Привязывает Telegram (tg_id) к текущей идентичности. Требуется X-Identity-Id и X-Token.""" + result = await idb.attach_telegram(session, identity.id, body.tg_id) + if not result: + raise HTTPException( + status_code=409, + detail="Этот Telegram уже привязан к другой идентичности", + ) + return IdentityResponse.model_validate(result) + + +@router.get("/me", response_model=IdentityResponse) +async def me( + identity=Depends(verify_identity_token), +): + """Текущая идентичность по заголовкам X-Identity-Id и X-Token.""" + return IdentityResponse.model_validate(identity) diff --git a/api/v2/routes/coupons.py b/api/v2/routes/coupons.py new file mode 100644 index 00000000..2cf50d78 --- /dev/null +++ b/api/v2/routes/coupons.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from api.v2.base_crud import generate_crud_router +from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate +from database.models import Coupon + +router = generate_crud_router( + model=Coupon, + schema_response=CouponResponse, + schema_create=CouponBase, + schema_update=CouponUpdate, + identifier_field="code", + parameter_name="code", + enabled_methods=["get_all", "get_one", "create", "update", "delete"], +) diff --git a/api/v2/routes/gifts.py b/api/v2/routes/gifts.py new file mode 100644 index 00000000..9ef431d3 --- /dev/null +++ b/api/v2/routes/gifts.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, Path +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.base_crud import generate_crud_router +from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse +from database.models import Gift, GiftUsage + +router = APIRouter() + +gift_router = generate_crud_router( + model=Gift, + schema_response=GiftResponse, + schema_create=GiftBase, + schema_update=GiftUpdate, + identifier_field="gift_id", + parameter_name="gift_id", + enabled_methods=["get_all", "get_one", "create", "update"], +) +router.include_router(gift_router, prefix="", tags=["Gifts"]) + + +@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"]) +async def get_gifts_by_tg_id( + tg_id: int = Path(...), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Список подарков по tg_id отправителя.""" + result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id)) + gifts = result.scalars().all() + if not gifts: + raise HTTPException(status_code=404, detail="Gifts not found") + return gifts + + +gift_usage_router = generate_crud_router( + model=GiftUsage, + schema_response=GiftUsageResponse, + schema_create=None, + schema_update=None, + identifier_field="gift_id", + enabled_methods=["get_all", "get_one", "delete"], +) +router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts", "GiftUsages"]) + + +@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"]) +async def delete_gift_with_usages( + gift_id: str = Path(..., description="ID подарка"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Удаляет подарок и все его использования.""" + result = await session.execute(select(Gift).where(Gift.gift_id == gift_id)) + gift = result.scalar_one_or_none() + if not gift: + raise HTTPException(status_code=404, detail="Gift not found") + await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id)) + await session.delete(gift) + await session.commit() + return {"message": "Подарок и связанные использования удалены"} diff --git a/api/v2/routes/identities.py b/api/v2/routes/identities.py new file mode 100644 index 00000000..3b19cc06 --- /dev/null +++ b/api/v2/routes/identities.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, HTTPException, Path, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.schemas.identities import ( + IdentityAttachEmail, + IdentityAttachTelegram, + IdentityCreate, + IdentityResponse, +) +from database import identities as idb + +router = APIRouter(tags=["Identities"]) + + +@router.post("/", response_model=IdentityResponse) +async def create_identity( + body: IdentityCreate, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Создаёт идентичность; можно указать email и/или tg_id.""" + email = body.email.strip().lower() if body.email and body.email.strip() else None + if not email and not body.tg_id: + raise HTTPException(status_code=400, detail="Укажите email и/или tg_id") + identity = await idb.create_identity(session, email=email, tg_id=body.tg_id) + return IdentityResponse.model_validate(identity) + + +@router.get("/{identity_id}", response_model=IdentityResponse) +async def get_identity( + identity_id: str = Path(...), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Возвращает идентичность по id.""" + identity = await idb.get_identity_by_id(session, identity_id) + if not identity: + raise HTTPException(status_code=404, detail="Identity not found") + return IdentityResponse.model_validate(identity) + + +@router.get("/by/email", response_model=IdentityResponse) +async def get_identity_by_email( + email: str = Query(..., min_length=1), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Возвращает идентичность по email.""" + identity = await idb.get_identity_by_email(session, email) + if not identity: + raise HTTPException(status_code=404, detail="Identity not found") + return IdentityResponse.model_validate(identity) + + +@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse) +async def get_identity_by_tg_id( + tg_id: int = Path(...), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Возвращает идентичность по Telegram ID.""" + identity = await idb.get_identity_by_tg_id(session, tg_id) + if not identity: + raise HTTPException(status_code=404, detail="Identity not found") + return IdentityResponse.model_validate(identity) + + +@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse) +async def attach_email( + identity_id: str = Path(...), + body: IdentityAttachEmail = ..., + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Привязывает email к идентичности.""" + identity = await idb.attach_email(session, identity_id, body.email) + if not identity: + raise HTTPException( + status_code=404, + detail="Identity not found или email уже привязан к другой идентичности", + ) + return IdentityResponse.model_validate(identity) + + +@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse) +async def attach_telegram( + identity_id: str = Path(...), + body: IdentityAttachTelegram = ..., + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Привязывает Telegram (tg_id) к идентичности.""" + identity = await idb.attach_telegram(session, identity_id, body.tg_id) + if not identity: + raise HTTPException( + status_code=404, + detail="Identity not found или tg_id уже привязан к другой идентичности", + ) + return IdentityResponse.model_validate(identity) diff --git a/api/v2/routes/keys.py b/api/v2/routes/keys.py new file mode 100644 index 00000000..9ab70733 --- /dev/null +++ b/api/v2/routes/keys.py @@ -0,0 +1,134 @@ +from datetime import datetime + +from fastapi import Body, Depends, HTTPException, Path, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate +from api.v2.base_crud import generate_crud_router +from database.models import Key, Tariff +from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster +from logger import logger + +router = generate_crud_router( + model=Key, + schema_response=KeyResponse, + schema_create=KeyBase, + schema_update=KeyUpdate, + identifier_field="tg_id", + extra_get_by_email=True, + enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"], +) + + +@router.delete("/by_email/{email}", response_model=dict) +async def delete_key_by_email( + email: str = Path(..., description="Email клиента"), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Удаляет ключ по email с кластера и из БД.""" + result = await session.execute(select(Key).where(Key.email == email)) + db_key = result.scalar_one_or_none() + if not db_key: + raise HTTPException(status_code=404, detail="Ключ не найден") + try: + await delete_key_from_cluster( + session=session, + email=db_key.email, + client_id=db_key.client_id, + cluster_id=db_key.server_id, + ) + await session.delete(db_key) + await session.commit() + logger.info(f"[API] Ключ удалён: {db_key.client_id}") + return {"message": "Ключ успешно удалён"} + except Exception as e: + logger.error(f"[API] Ошибка при удалении ключа: {e}") + raise HTTPException(status_code=500, detail="Ошибка при удалении ключа") + + +@router.get("/routers/{tg_id}", response_model=list[KeyResponse]) +async def get_router_keys_by_tg_id( + tg_id: int = Path(..., description="Telegram ID пользователя"), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Список ключей пользователя с тарифами группы routers.""" + tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers")) + tariff_ids = [row[0] for row in tariffs_result.all()] + if not tariff_ids: + return [] + keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids))) + return keys_result.scalars().all() + + +@router.patch("/edit/by_email/{email}", response_model=KeyResponse) +async def edit_key_by_email( + email: str = Path(..., description="Email клиента"), + key_update: KeyUpdate = Body(...), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Обновляет ключ по email и синхронизирует с кластером.""" + result = await session.execute(select(Key).where(Key.email == email)) + db_key = result.scalar_one_or_none() + if not db_key: + raise HTTPException(status_code=404, detail="Ключ не найден") + for field, value in key_update.model_dump(exclude_unset=True).items(): + if field == "expiry_time" and value is not None: + if isinstance(value, int): + ms = value + elif isinstance(value, datetime): + ms = int(value.timestamp() * 1000) + else: + raise HTTPException(status_code=400, detail="Некорректный формат времени") + setattr(db_key, field, ms) + else: + setattr(db_key, field, value) + try: + new_expiry_time = db_key.expiry_time + await renew_key_in_cluster( + cluster_id=db_key.server_id, + email=db_key.email, + client_id=db_key.client_id, + new_expiry_time=new_expiry_time, + total_gb=getattr(db_key, "traffic_limit", None), + session=session, + hwid_device_limit=getattr(db_key, "device_limit", None), + reset_traffic=True, + ) + await session.commit() + logger.info(f"[API] Ключ обновлён: {db_key.client_id}") + return db_key + except Exception as e: + logger.error(f"[API] Ошибка при обновлении ключа: {e}") + raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа") + + +@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED) +async def create_key_api( + payload: KeyCreateRequest = Body(...), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_admin), +): + """Создаёт ключ на кластере.""" + try: + await create_key_on_cluster( + cluster_id=payload.cluster_id, + tg_id=payload.tg_id, + client_id=payload.client_id, + email=payload.email or f"{payload.tg_id}_key", + expiry_timestamp=payload.expiry_timestamp, + plan=payload.tariff_id, + session=session, + remnawave_link=payload.remnawave_link, + hwid_limit=payload.hwid_limit, + traffic_limit_bytes=payload.traffic_limit_bytes, + is_trial=payload.is_trial or False, + ) + return {"message": "Ключ успешно создан"} + except Exception as e: + logger.error(f"[API] Ошибка при создании ключа: {e}") + raise HTTPException(status_code=500, detail="Ошибка при создании ключа") diff --git a/api/v2/routes/management.py b/api/v2/routes/management.py new file mode 100644 index 00000000..cf8927ab --- /dev/null +++ b/api/v2/routes/management.py @@ -0,0 +1,207 @@ +import asyncio +import os +import re +import subprocess +import sys +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_identity_admin, verify_identity_admin_short +from database import async_session_maker +from config import API_TOKEN, BOT_SERVICE +from core.bootstrap import MANAGEMENT_CONFIG +from core.settings.management_config import update_management_config +from database.models import Key, Server, User +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: + """Перезапуск процесса бота (systemctl или execv).""" + 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(identity=Depends(verify_identity_admin)): + """Текущий статус: maintenance и management config.""" + return { + "maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)), + "management": dict(MANAGEMENT_CONFIG or {}), + } + + +@router.post("/maintenance") +async def set_maintenance( + payload: MaintenanceUpdate, + identity=Depends(verify_identity_admin), + 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, + identity=Depends(verify_identity_admin), +): + """Запуск перезапуска бота в фоне.""" + background.add_task(_restart_bot) + return {"status": "restarting"} + + +@router.post("/change-domain") +async def change_domain( + payload: DomainChange, + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Массовая замена домена в ключах и remnawave_link.""" + 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( + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Сбрасывает trial=0 у пользователей без ключей.""" + 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(identity=Depends(verify_identity_admin)): + """Запуск бэкапа БД в фоне.""" + + 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( + identity=Depends(verify_identity_admin), + 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, + identity=Depends(verify_identity_admin_short), +): + """Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки.""" + 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") + async with async_session_maker() as session: + 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=None, messages_per_second=rate) + stats = await broadcast_service.broadcast(messages, workers=workers) + return { + "success": True, + "message": "Broadcast completed", + "recipients": total_users, + "stats": stats, + } diff --git a/api/v2/routes/misc.py b/api/v2/routes/misc.py new file mode 100644 index 00000000..b97d2d50 --- /dev/null +++ b/api/v2/routes/misc.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException, Path +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.schemas import ( + BlockedUserResponse, + ManualBanResponse, + NotificationResponse, + PaymentResponse, + TemporaryDataResponse, + TrackingSourceResponse, +) +from api.v2.base_crud import generate_crud_router +from database import get_tracking_source_stats +from database.models import ( + BlockedUser, + ManualBan, + Notification, + Payment, + TemporaryData, + TrackingSource, +) + +router = APIRouter() + +router.include_router( + generate_crud_router( + model=Payment, + schema_response=PaymentResponse, + schema_create=None, + schema_update=None, + identifier_field="id", + enabled_methods=["get_all", "get_one", "delete"], + ), + prefix="/payments", + tags=["Payments"], + dependencies=[Depends(verify_identity_admin)], +) + + +@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"]) +async def get_payments_by_tg_id( + tg_id: int = Path(...), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Список платежей по tg_id пользователя.""" + result = await session.execute(select(Payment).where(Payment.tg_id == tg_id)) + payments = result.scalars().all() + if not payments: + raise HTTPException(status_code=404, detail="Payments not found") + return payments + + +router.include_router( + generate_crud_router( + model=Notification, + schema_response=NotificationResponse, + schema_create=None, + schema_update=None, + identifier_field="tg_id", + enabled_methods=["get_all", "get_one", "delete"], + ), + prefix="/notifications", + tags=["Notifications"], + dependencies=[Depends(verify_identity_admin)], +) + +router.include_router( + generate_crud_router( + model=ManualBan, + schema_response=ManualBanResponse, + schema_create=None, + schema_update=None, + identifier_field="tg_id", + enabled_methods=["get_all", "get_one", "delete"], + ), + prefix="/manual-bans", + tags=["Bans"], + dependencies=[Depends(verify_identity_admin)], +) + +router.include_router( + generate_crud_router( + model=BlockedUser, + schema_response=BlockedUserResponse, + schema_create=None, + schema_update=None, + identifier_field="tg_id", + enabled_methods=["get_all", "get_one", "delete"], + ), + prefix="/blocked-users", + tags=["Bans"], + dependencies=[Depends(verify_identity_admin)], +) + +router.include_router( + generate_crud_router( + model=TemporaryData, + schema_response=TemporaryDataResponse, + schema_create=None, + schema_update=None, + identifier_field="tg_id", + enabled_methods=["get_all", "get_one", "delete"], + ), + prefix="/temporary-data", + tags=["TemporaryData"], + dependencies=[Depends(verify_identity_admin)], +) + +router.include_router( + generate_crud_router( + model=TrackingSource, + schema_response=TrackingSourceResponse, + schema_create=None, + schema_update=None, + identifier_field="id", + enabled_methods=["get_all", "delete"], + ), + prefix="/tracking-sources", + tags=["TrackingSources"], + dependencies=[Depends(verify_identity_admin)], +) + + +@router.get( + "/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)] +) +async def get_tracking_source_with_stats( + code: str, + session: AsyncSession = Depends(get_session), +): + """Источник по коду со статистикой регистраций и платежей.""" + result = await session.execute(select(TrackingSource).where(TrackingSource.code == code)) + source = result.scalar_one_or_none() + if not source: + raise HTTPException(status_code=404, detail="Tracking source not found") + stats = await get_tracking_source_stats(session, code) + return TrackingSourceResponse( + id=source.id, + name=source.name, + code=source.code, + type=source.type, + created_by=source.created_by, + created_at=source.created_at, + registrations=(stats["registrations"] if stats else 0), + trials=(stats["trials"] if stats else 0), + payments=(stats["payments"] if stats else 0), + total_amount=(float(stats["total_amount"]) if stats else 0.0), + monthly=(stats["monthly"] if stats and "monthly" in stats else []), + ) diff --git a/api/v2/routes/modules.py b/api/v2/routes/modules.py new file mode 100644 index 00000000..43ed47df --- /dev/null +++ b/api/v2/routes/modules.py @@ -0,0 +1,119 @@ +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_identity_admin +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[3] / "modules" + + +class ModuleAction(BaseModel): + action: Literal["start", "stop", "restart"] + + +def _available_module_names() -> list[str]: + """Имена модулей из папки modules, прошедшие проверку безопасности.""" + 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: + """Состояние модуля: enabled, loaded, autostart.""" + 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 из папки модуля.""" + 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(identity=Depends(verify_identity_admin)): + """Список модулей с состоянием и локальной версией.""" + 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, identity=Depends(verify_identity_admin)): + """Запуск, остановка или перезапуск модуля.""" + 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)} diff --git a/api/v2/routes/partners.py b/api/v2/routes/partners.py new file mode 100644 index 00000000..17dedd47 --- /dev/null +++ b/api/v2/routes/partners.py @@ -0,0 +1,689 @@ +import csv +from datetime import datetime +from io import StringIO +import re + +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_identity_admin + +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: + 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="Смещение"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Список всех партнёров со статистикой. Требуется админ (X-Identity-Id + X-Token).""" + 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 + default_percent = _default_partner_percent() + partners_list = [] + for partner in partners: + percent_value = partner[2] + percent_custom = bool(partner[3]) + percent = float(percent_value) if (percent_custom and percent_value is not None) else 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( + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Общая статистика партнёрской программы.""" + 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="Новый процент партнёра"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Обновляет данные партнёра (баланс и процент).""" + 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 + ) + 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 партнёра"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Партнёрские данные по tg_id и список приглашённых.""" + meta_sql = text( + """ + SELECT COALESCE(u.partner_balance, 0), u.partner_percent, COALESCE(u.partner_percent_custom, false), 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), + (SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id), + (SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success') + 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, percent_custom = meta_row[1], 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 приглашённого"), + identity=Depends(verify_identity_admin), + 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 приглашённого"), + identity=Depends(verify_identity_admin), + 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)"), + identity=Depends(verify_identity_admin), + 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"), + identity=Depends(verify_identity_admin), + 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 партнёра"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Список приглашённых партнёра.""" + invited_sql = text( + """ + SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0), + (SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id), + (SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success') + 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), + offset: int = Query(0, ge=0), + partner_tg_id: int | None = Query(None), + identity=Depends(verify_identity_admin), + 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 = [ + { + "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, + } + for row in result.fetchall() + ] + 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), + offset: int = Query(0, ge=0), + partner_tg_id: int | None = Query(None), + identity=Depends(verify_identity_admin), + 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 = [ + { + "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, + } + for row in result.fetchall() + ] + 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 заявки"), + identity=Depends(verify_identity_admin), + 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 заявки"), + identity=Depends(verify_identity_admin), + 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 партнёра"), + identity=Depends(verify_identity_admin), + 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="Новый код партнёра (латиница/цифры/_)"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Обновляет код партнёрской ссылки.""" + raw = (code or "").strip().lower() + if not raw: + return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400) + 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( + identity=Depends(verify_identity_admin), + 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 партнёра"), + identity=Depends(verify_identity_admin), + 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}"} + ) diff --git a/api/v2/routes/payment_links.py b/api/v2/routes/payment_links.py new file mode 100644 index 00000000..2f7b1c7f --- /dev/null +++ b/api/v2/routes/payment_links.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_token +from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse +from database import identities as idb +from handlers.payments import create_payment_link +from handlers.payments.payment_links import PaymentLinkRequest + +router = APIRouter(tags=["PaymentLinks"]) + + +async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int: + """Возвращает tg_id из body.tg_id или из identity_id; иначе исключение.""" + if body.tg_id is not None: + return body.tg_id + if body.identity_id: + tg_id = await idb.resolve_tg_id(session, body.identity_id) + if tg_id is not None: + return tg_id + raise HTTPException( + status_code=400, + detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.", + ) + raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id") + + +@router.post("/", response_model=PaymentLinkCreateResponse) +async def create_link( + body: PaymentLinkCreateRequest, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + """Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id.""" + tg_id = await _resolve_tg_id(body, session) + request = PaymentLinkRequest( + tg_id=tg_id, + amount=body.amount, + currency=body.currency or "RUB", + provider_id=body.provider_id, + success_url=body.success_url, + failure_url=body.failure_url, + metadata=body.metadata, + ) + result = await create_payment_link(session, request) + return PaymentLinkCreateResponse( + success=result.success, + payment_id=result.payment_id, + payment_url=result.payment_url, + error=result.error, + ) diff --git a/api/v2/routes/referrals.py b/api/v2/routes/referrals.py new file mode 100644 index 00000000..db3764a8 --- /dev/null +++ b/api/v2/routes/referrals.py @@ -0,0 +1,37 @@ +from fastapi import Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.base_crud import generate_crud_router +from api.v2.schemas import ReferralResponse +from database.models import Referral + +router = generate_crud_router( + model=Referral, + schema_response=ReferralResponse, + schema_create=None, + schema_update=None, + identifier_field="referrer_tg_id", + parameter_name="referrer_tg_id", + enabled_methods=["get_all", "get_one", "get_all_by_field"], +) + + +@router.delete("/one") +async def delete_one_referral( + referrer_tg_id: int = Query(..., description="ID пригласившего"), + referred_tg_id: int = Query(..., description="ID приглашённого"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Удаляет одну связь реферала по паре referrer/referred.""" + result = await session.execute( + select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id) + ) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail="Referral not found") + await session.delete(obj) + await session.commit() + return {"status": "deleted_one"} diff --git a/api/v2/routes/root.py b/api/v2/routes/root.py new file mode 100644 index 00000000..d7c2dd7e --- /dev/null +++ b/api/v2/routes/root.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["Root"]) + + +@router.get("/api", include_in_schema=False) +async def root(): + return {"message": "SoloBot API v2", "docs": "/api/docs"} + + +@router.get("/api/version", include_in_schema=True) +async def version(): + return {"version": 2, "api": "v2"} diff --git a/api/v2/routes/servers.py b/api/v2/routes/servers.py new file mode 100644 index 00000000..d978daa0 --- /dev/null +++ b/api/v2/routes/servers.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate +from api.v2.base_crud import generate_crud_router +from database.models import Server + +router = generate_crud_router( + model=Server, + schema_response=ServerResponse, + schema_create=ServerBase, + schema_update=ServerUpdate, + identifier_field="server_name", + parameter_name="server_name", + enabled_methods=["get_all", "get_one", "create", "update", "delete"], +) diff --git a/api/v2/routes/settings.py b/api/v2/routes/settings.py new file mode 100644 index 00000000..53a12eb5 --- /dev/null +++ b/api/v2/routes/settings.py @@ -0,0 +1,148 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.schemas import SettingResponse, SettingUpsert +from database import settings_cache +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 database.models import Setting +from database.settings import set_setting + +router = APIRouter() + + +class ConfigUpdatePayload(BaseModel): + value: dict[str, Any] | None = None + + +@router.get("/", response_model=list[SettingResponse]) +async def get_all_settings(identity=Depends(verify_identity_admin)): + """Список всех настроек (из кэша, без запроса к БД).""" + return settings_cache.get_all() + + +@router.get("/configs") +async def get_configs(identity=Depends(verify_identity_admin)): + """Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs).""" + 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, + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs).""" + 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, identity=Depends(verify_identity_admin)): + """Настройка по ключу (из кэша, без запроса к БД).""" + obj = settings_cache.get(key) + 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, + identity=Depends(verify_identity_admin), + 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) + settings_cache.update( + key, + obj.value, + obj.description, + created_at=getattr(obj, "created_at", None), + updated_at=getattr(obj, "updated_at", None), + ) + return obj + + +@router.delete("/{key}", response_model=dict) +async def delete_setting( + key: str, + identity=Depends(verify_identity_admin), + 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() + settings_cache.delete(key) + return {"detail": "Setting deleted"} diff --git a/api/v2/routes/tariffs.py b/api/v2/routes/tariffs.py new file mode 100644 index 00000000..d9a15176 --- /dev/null +++ b/api/v2/routes/tariffs.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate +from api.v2.base_crud import generate_crud_router +from database.models import Tariff + +router = generate_crud_router( + model=Tariff, + schema_response=TariffResponse, + schema_create=TariffBase, + schema_update=TariffUpdate, + identifier_field="name", + parameter_name="name", + enabled_methods=["get_all", "get_one", "create", "update", "delete"], +) diff --git a/api/v2/routes/users.py b/api/v2/routes/users.py new file mode 100644 index 00000000..fba39547 --- /dev/null +++ b/api/v2/routes/users.py @@ -0,0 +1,52 @@ +import asyncio + +from fastapi import Depends, HTTPException, Path +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import get_session, verify_identity_admin +from api.v2.schemas import UserBase, UserResponse, UserUpdate +from api.v2.base_crud import generate_crud_router +from database import delete_user_data, get_servers +from database.models import Key, User +from handlers.keys.operations import delete_key_from_cluster +from logger import logger + +router = generate_crud_router( + model=User, + schema_response=UserResponse, + schema_create=UserBase, + schema_update=UserUpdate, + identifier_field="tg_id", + enabled_methods=["get_all", "get_one", "create", "update"], +) + + +@router.delete("/{tg_id}", response_model=dict) +async def delete_user( + tg_id: int = Path(..., description="Telegram ID пользователя"), + identity=Depends(verify_identity_admin), + session: AsyncSession = Depends(get_session), +): + """Удаляет пользователя и его ключи на серверах.""" + try: + result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id)) + key_records = result.all() + + async def delete_keys_from_servers(): + try: + servers = await get_servers(session=session) + tasks = [] + for email, client_id in key_records: + for cluster_id in servers: + tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session)) + await asyncio.gather(*tasks, return_exceptions=True) + except Exception as e: + logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}") + + await delete_keys_from_servers() + await delete_user_data(session, tg_id) + return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."} + except Exception as e: + logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}") + raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя") diff --git a/api/v2/schemas/__init__.py b/api/v2/schemas/__init__.py new file mode 100644 index 00000000..671e1658 --- /dev/null +++ b/api/v2/schemas/__init__.py @@ -0,0 +1,30 @@ +from api.v1.schemas import ( + CouponBase, + CouponResponse, + CouponUpdate, + CouponUsageResponse, + GiftBase, + GiftResponse, + GiftUpdate, + GiftUsageResponse, + KeyDetailsResponse, + KeyResponse, + ReferralResponse, + ServerBase, + ServerResponse, + ServerUpdate, + TariffBase, + TariffResponse, + TariffUpdate, + UserBase, + UserResponse, + UserUpdate, + BlockedUserResponse, + ManualBanResponse, + NotificationResponse, + PaymentResponse, + TemporaryDataResponse, + TrackingSourceResponse, +) +from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate +from api.v1.schemas.settings import SettingResponse, SettingUpsert diff --git a/api/v2/schemas/identities.py b/api/v2/schemas/identities.py new file mode 100644 index 00000000..57df9259 --- /dev/null +++ b/api/v2/schemas/identities.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class IdentityCreate(BaseModel): + email: str | None = Field(None, description="Почта для привязки") + tg_id: int | None = Field(None, description="Telegram ID для привязки") + + +class IdentityResponse(BaseModel): + id: str + email: str | None + tg_id: int | None + is_admin: bool = False + created_at: datetime | None + updated_at: datetime | None + + class Config: + from_attributes = True + + +class RegisterByEmailRequest(BaseModel): + email: str = Field(..., min_length=1) + password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)") + + +class RegisterResponse(BaseModel): + identity_id: str + token: str + + +class LoginRequest(BaseModel): + email: str = Field(..., min_length=1) + password: str = Field(...) + + +class LoginResponse(BaseModel): + identity_id: str + token: str + + +class LoginTelegramRequest(BaseModel): + """Данные от Telegram Login Widget (кнопка «Войти через Telegram»).""" + + id: int = Field(..., description="Telegram user id (tg_id)") + first_name: str = Field("") + last_name: str | None = None + username: str | None = None + photo_url: str | None = None + auth_date: int = Field(..., description="Unix timestamp от Telegram") + hash: str = Field(..., description="HMAC подпись для проверки на бэкенде") + + +class LinkTelegramRequest(BaseModel): + tg_id: int = Field(...) + + +class IdentityAttachEmail(BaseModel): + email: str = Field(..., min_length=1) + + +class IdentityAttachTelegram(BaseModel): + tg_id: int = Field(...) diff --git a/api/v2/schemas/payment_links.py b/api/v2/schemas/payment_links.py new file mode 100644 index 00000000..cdfc46a6 --- /dev/null +++ b/api/v2/schemas/payment_links.py @@ -0,0 +1,24 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class PaymentLinkCreateRequest(BaseModel): + tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)") + identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки") + amount: int | float = Field(..., gt=0, description="Сумма оплаты") + currency: str = Field(default="RUB", description="Валюта (например RUB)") + provider_id: str = Field( + ..., + description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др.", + ) + success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты") + failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты") + metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные") + + +class PaymentLinkCreateResponse(BaseModel): + success: bool + payment_id: str | None = None + payment_url: str | None = None + error: str | None = None diff --git a/core/bootstrap.py b/core/bootstrap.py index bf1af583..1487260d 100644 --- a/core/bootstrap.py +++ b/core/bootstrap.py @@ -1,4 +1,4 @@ -from database import async_session_maker +from database import async_session_maker, settings_cache from database.db import warm_pool from database.tariffs import initialize_all_tariff_weights @@ -25,3 +25,4 @@ async def bootstrap() -> None: await load_management_config(session) await load_tariffs_config(session) await session.commit() + await settings_cache.load(session) diff --git a/core/settings/buttons_config.py b/core/settings/buttons_config.py index 70ac97a3..285ebe6d 100644 --- a/core/settings/buttons_config.py +++ b/core/settings/buttons_config.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_BUTTONS_CONFIG @@ -63,3 +64,4 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo BUTTONS_CONFIG.clear() BUTTONS_CONFIG.update(buttons_config) + settings_cache.update("BUTTONS_CONFIG", buttons_config) diff --git a/core/settings/management_config.py b/core/settings/management_config.py index 5cbb01f9..3d9df4f0 100644 --- a/core/settings/management_config.py +++ b/core/settings/management_config.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_MANAGEMENT_CONFIG @@ -58,3 +59,4 @@ async def update_management_config(session: AsyncSession, new_values: dict[str, MANAGEMENT_CONFIG.clear() MANAGEMENT_CONFIG.update(management_config) + settings_cache.update(MANAGEMENT_SETTING_KEY, management_config) diff --git a/core/settings/modes_config.py b/core/settings/modes_config.py index 1aa87e54..f82fad80 100644 --- a/core/settings/modes_config.py +++ b/core/settings/modes_config.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_MODES_CONFIG @@ -55,3 +56,4 @@ async def update_modes_config(session: AsyncSession, new_values: dict[str, bool] MODES_CONFIG.clear() MODES_CONFIG.update(modes_config) + settings_cache.update("MODES_CONFIG", modes_config) diff --git a/core/settings/money_config.py b/core/settings/money_config.py index dc3ee775..53cfc0a8 100644 --- a/core/settings/money_config.py +++ b/core/settings/money_config.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_MONEY_CONFIG @@ -73,3 +74,4 @@ async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) MONEY_CONFIG.clear() MONEY_CONFIG.update(money_config) + settings_cache.update("MONEY_CONFIG", money_config) diff --git a/core/settings/notifications_config.py b/core/settings/notifications_config.py index 21685e7c..d3b94af7 100644 --- a/core/settings/notifications_config.py +++ b/core/settings/notifications_config.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG @@ -57,3 +58,4 @@ async def update_notifications_config(session: AsyncSession, new_values: dict[st NOTIFICATIONS_CONFIG.clear() NOTIFICATIONS_CONFIG.update(notifications_config) + settings_cache.update("NOTIFICATIONS_CONFIG", notifications_config) diff --git a/core/settings/payments_config.py b/core/settings/payments_config.py index 98099663..48402fc5 100644 --- a/core/settings/payments_config.py +++ b/core/settings/payments_config.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache from ..defaults import DEFAULT_PAYMENTS_CONFIG @@ -55,3 +56,4 @@ async def update_payments_config(session: AsyncSession, new_values: dict[str, bo PAYMENTS_CONFIG.clear() PAYMENTS_CONFIG.update(payments_config) + settings_cache.update("PAYMENTS_CONFIG", payments_config) diff --git a/core/settings/providers_order_config.py b/core/settings/providers_order_config.py index 25bd06fd..9570da28 100644 --- a/core/settings/providers_order_config.py +++ b/core/settings/providers_order_config.py @@ -3,6 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache + PROVIDERS_ORDER: dict[str, int] = {} @@ -36,3 +38,4 @@ async def update_providers_order(session: AsyncSession, new_order: dict[str, int PROVIDERS_ORDER.clear() PROVIDERS_ORDER.update(new_order) + settings_cache.update("PROVIDERS_ORDER", new_order) diff --git a/core/settings/tariffs_config.py b/core/settings/tariffs_config.py index fbb2ebaf..1e251965 100644 --- a/core/settings/tariffs_config.py +++ b/core/settings/tariffs_config.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import Setting +from database import settings_cache + TARIFFS_CONFIG: dict[str, Any] = { "ALLOW_DOWNGRADE": True, @@ -62,6 +64,7 @@ async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any TARIFFS_CONFIG.clear() TARIFFS_CONFIG.update(tariffs_config) + settings_cache.update("TARIFFS_CONFIG", tariffs_config) def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int: diff --git a/database/__init__.py b/database/__init__.py index a2c40156..0ee6db64 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -2,6 +2,7 @@ from .bans import * from .coupons import * from .db import async_session_maker from .gifts import * +from . import identities from .hot_leads import * from .init_db import * from .keys import * @@ -9,6 +10,7 @@ from .notifications import * from .payments import * from .referrals import * from .servers import * +from .settings_cache import settings_cache from .statistics import * from .tariffs import * from .temporary_data import * diff --git a/database/db.py b/database/db.py index 6fa61ff1..45374aa1 100644 --- a/database/db.py +++ b/database/db.py @@ -8,7 +8,7 @@ from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW -MAX_UPDATE_AGE_SEC = 28 +MAX_UPDATE_AGE_SEC = 15 engine = create_async_engine( DATABASE_URL, diff --git a/database/identities.py b/database/identities.py new file mode 100644 index 00000000..91e6873d --- /dev/null +++ b/database/identities.py @@ -0,0 +1,197 @@ +import hashlib +import secrets +from datetime import datetime, timedelta + +import bcrypt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import API_TOKEN_TTL_DAYS +from database.models import Admin, Identity, User + + +_BCRYPT_MAX_PASSWORD_BYTES = 72 +_BCRYPT_ROUNDS = 12 + + +def _password_bytes(password: str) -> bytes: + """Пароль в байтах, не длиннее 72 байт (ограничение bcrypt).""" + raw = password.encode("utf-8") + if len(raw) > _BCRYPT_MAX_PASSWORD_BYTES: + return raw[:_BCRYPT_MAX_PASSWORD_BYTES] + return raw + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def hash_password(password: str) -> str: + """Хеш пароля через bcrypt (соль уникальна на каждый пароль).""" + salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS) + return bcrypt.hashpw(_password_bytes(password), salt).decode("ascii") + + +def check_password(password: str, password_hash: str | None) -> bool: + if not password_hash: + return False + try: + return bcrypt.checkpw(_password_bytes(password), password_hash.encode("ascii")) + except Exception: + return False + + +def generate_token() -> str: + return secrets.token_urlsafe(32) + + +async def create_identity( + session: AsyncSession, + email: str | None = None, + tg_id: int | None = None, +) -> Identity: + """Создаёт идентичность; можно задать email и/или tg_id.""" + identity = Identity(email=email.strip().lower() if email else None, tg_id=tg_id) + session.add(identity) + await session.flush() + if tg_id: + await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id)) + await session.commit() + await session.refresh(identity) + return identity + + +async def get_identity_by_id(session: AsyncSession, identity_id: str) -> Identity | None: + """Возвращает идентичность по id.""" + result = await session.execute(select(Identity).where(Identity.id == identity_id)) + return result.scalar_one_or_none() + + +async def get_identity_by_email(session: AsyncSession, email: str) -> Identity | None: + """Возвращает идентичность по email.""" + if not email or not email.strip(): + return None + result = await session.execute(select(Identity).where(Identity.email == email.strip().lower())) + return result.scalar_one_or_none() + + +async def get_identity_by_tg_id(session: AsyncSession, tg_id: int) -> Identity | None: + """Возвращает идентичность по tg_id.""" + result = await session.execute(select(Identity).where(Identity.tg_id == tg_id)) + return result.scalar_one_or_none() + + +async def get_identity_by_token_hash(session: AsyncSession, token_hash: str) -> Identity | None: + """Возвращает идентичность по хешу API-токена.""" + result = await session.execute(select(Identity).where(Identity.api_token_hash == token_hash)) + return result.scalar_one_or_none() + + +async def issue_token_for_identity(session: AsyncSession, identity: Identity) -> str: + """Генерирует токен, сохраняет хеш и token_issued_at в identity, возвращает токен (показать один раз).""" + token = generate_token() + identity.api_token_hash = hash_token(token) + identity.token_issued_at = datetime.utcnow() + await session.commit() + await session.refresh(identity) + return token + + +def _is_token_expired(identity: Identity) -> bool: + """Проверяет, истёк ли срок действия токена (если задан API_TOKEN_TTL_DAYS).""" + if API_TOKEN_TTL_DAYS is None or identity.token_issued_at is None: + return False + expiry = identity.token_issued_at + timedelta(days=API_TOKEN_TTL_DAYS) + return datetime.utcnow() >= expiry + + +async def create_identity_with_token( + session: AsyncSession, + email: str | None = None, + password: str | None = None, + tg_id: int | None = None, +) -> tuple[Identity, str]: + """Создаёт идентичность и выдаёт API-токен. При регистрации по почте передать email и password.""" + identity = await create_identity(session, email=email, tg_id=tg_id) + if password: + identity.password_hash = hash_password(password) + await session.commit() + await session.refresh(identity) + token = await issue_token_for_identity(session, identity) + return identity, token + + +async def verify_identity_token(session: AsyncSession, identity_id: str, token: str) -> Identity | None: + """Проверяет пару identity_id + token и срок действия токена; возвращает Identity или None.""" + identity = await get_identity_by_id(session, identity_id) + if not identity or not identity.api_token_hash: + return None + if hash_token(token) != identity.api_token_hash: + return None + if _is_token_expired(identity): + return None + return identity + + +async def login_by_email(session: AsyncSession, email: str, password: str) -> tuple[Identity, str] | None: + """Вход по email и паролю: проверяет пароль, выдаёт новый токен; возвращает (identity, token) или None.""" + identity = await get_identity_by_email(session, email) + if not identity or not check_password(password, identity.password_hash): + return None + token = await issue_token_for_identity(session, identity) + return identity, token + + +async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None: + """По identity_id возвращает tg_id, если привязан.""" + identity = await get_identity_by_id(session, identity_id) + return identity.tg_id if identity else None + + +async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | None: + """Привязывает email к идентичности.""" + identity = await get_identity_by_id(session, identity_id) + if not identity: + return None + email_clean = email.strip().lower() if email else None + if not email_clean: + return identity + existing = await get_identity_by_email(session, email_clean) + if existing and existing.id != identity_id: + return None + identity.email = email_clean + await session.commit() + await session.refresh(identity) + return identity + + +async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -> Identity | None: + """Привязывает Telegram (tg_id) к идентичности и связывает User с identity. Если tg_id в admins — выставляет is_admin.""" + identity = await get_identity_by_id(session, identity_id) + if not identity: + return None + existing = await get_identity_by_tg_id(session, tg_id) + if existing and existing.id != identity_id: + return None + identity.tg_id = tg_id + admin_row = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + if admin_row.scalar_one_or_none(): + identity.is_admin = True + await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity_id)) + await session.commit() + await session.refresh(identity) + return identity + + +async def get_or_create_identity_for_tg(session: AsyncSession, tg_id: int) -> Identity: + """Для tg_id возвращает существующую идентичность или создаёт новую и привязывает User.""" + identity = await get_identity_by_tg_id(session, tg_id) + if identity: + return identity + identity = Identity(tg_id=tg_id) + session.add(identity) + await session.flush() + await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id)) + await session.commit() + await session.refresh(identity) + return identity diff --git a/database/keys.py b/database/keys.py index a50d8a35..15f39dfb 100644 --- a/database/keys.py +++ b/database/keys.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from database.models import Key, User +from database.users import invalidate_user_snapshot from logger import logger @@ -81,6 +82,7 @@ async def store_key( logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}") await session.commit() + invalidate_user_snapshot(tg_id) except SQLAlchemyError as e: logger.error(f"❌ Ошибка при сохранении ключа: {e}") @@ -157,10 +159,18 @@ async def get_key_count(session: AsyncSession, tg_id: int) -> int: 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) + tg_id_for_cache = None + if isinstance(identifier, str): + res = await session.execute(select(Key.tg_id).where(Key.client_id == identifier).limit(1)) + tg_id_for_cache = res.scalar_one_or_none() + else: + tg_id_for_cache = identifier + stmt = delete(Key).where(Key.tg_id == identifier if isinstance(identifier, int) else Key.client_id == identifier) await session.execute(stmt) if commit: await session.commit() + if tg_id_for_cache is not None: + invalidate_user_snapshot(tg_id_for_cache) logger.info(f"Ключ с идентификатором {identifier} удалён") diff --git a/database/models.py b/database/models.py index c1340e16..350c499d 100644 --- a/database/models.py +++ b/database/models.py @@ -36,10 +36,32 @@ class DictLikeMixin: return {column.name: getattr(self, column.name) for column in self.__table__.columns} +class Identity(DictLikeMixin, Base): + """Слой идентификации: к одному identity можно привязать email и/или Telegram (tg_id).""" + + __tablename__ = "identities" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + email = Column(String(255), unique=True, nullable=True, index=True) + tg_id = Column(BigInteger, unique=True, nullable=True, index=True) + api_token_hash = Column(String(64), nullable=True, index=True) + token_issued_at = Column(DateTime, nullable=True) + password_hash = Column(String(64), nullable=True) + is_admin = Column(Boolean, nullable=False, server_default=text("false")) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class User(DictLikeMixin, Base): __tablename__ = "users" tg_id = Column(BigInteger, primary_key=True) + identity_id = Column( + String(36), + ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + ) username = Column(String) first_name = Column(String) last_name = Column(String) @@ -162,7 +184,7 @@ class ServerSpecialgroup(DictLikeMixin, Base): class Payment(DictLikeMixin, Base): __tablename__ = "payments" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) tg_id = Column(BigInteger, ForeignKey("users.tg_id")) amount = Column(Float) payment_system = Column(String) diff --git a/database/settings_cache.py b/database/settings_cache.py new file mode 100644 index 00000000..280ae66c --- /dev/null +++ b/database/settings_cache.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import threading +from datetime import datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import Setting + + +class SettingsCache: + _cache: dict[str, dict[str, Any]] + _lock: threading.Lock + + def __init__(self) -> None: + self._cache = {} + self._lock = threading.Lock() + + @staticmethod + def _row_to_item(s: Setting) -> dict[str, Any]: + return { + "key": s.key, + "value": s.value, + "description": s.description, + "created_at": getattr(s, "created_at", None), + "updated_at": getattr(s, "updated_at", None), + } + + async def load(self, session: AsyncSession) -> None: + result = await session.execute(select(Setting)) + rows = result.scalars().all() + with self._lock: + self._cache.clear() + for s in rows: + self._cache[s.key] = self._row_to_item(s) + + def get_all(self) -> list[dict[str, Any]]: + with self._lock: + return [dict(x) for x in self._cache.values()] + + def get(self, key: str) -> dict[str, Any] | None: + with self._lock: + return dict(self._cache[key]) if key in self._cache else None + + def update( + self, + key: str, + value: Any, + description: str | None = None, + created_at: datetime | None = None, + updated_at: datetime | None = None, + ) -> None: + now = datetime.utcnow() + with self._lock: + if key in self._cache: + self._cache[key]["value"] = value + self._cache[key]["updated_at"] = updated_at if updated_at is not None else now + if description is not None: + self._cache[key]["description"] = description + else: + self._cache[key] = { + "key": key, + "value": value, + "description": description, + "created_at": created_at if created_at is not None else now, + "updated_at": updated_at if updated_at is not None else now, + } + + def delete(self, key: str) -> None: + with self._lock: + self._cache.pop(key, None) + + +settings_cache = SettingsCache() + + +async def load(session: AsyncSession) -> None: + await settings_cache.load(session) diff --git a/database/users.py b/database/users.py index e51e1e3a..c4da03bd 100644 --- a/database/users.py +++ b/database/users.py @@ -1,5 +1,6 @@ from datetime import datetime +from cachetools import TTLCache from sqlalchemy import delete, exists, func, or_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.exc import SQLAlchemyError @@ -20,6 +21,13 @@ from database.models import ( ) from logger import logger +_SNAPSHOT_CACHE: TTLCache[int, tuple[int, int]] = TTLCache(maxsize=150_000, ttl=30) +_EXISTS_CACHE: TTLCache[int, bool] = TTLCache(maxsize=150_000, ttl=60) + + +def invalidate_user_snapshot(tg_id: int) -> None: + _SNAPSHOT_CACHE.pop(tg_id, None) + async def add_user( session: AsyncSession, @@ -53,6 +61,7 @@ async def add_user( return False if commit: await session.commit() + _EXISTS_CACHE[tg_id] = True logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})") return True except SQLAlchemyError as e: @@ -82,9 +91,15 @@ async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> No async def check_user_exists(session: AsyncSession, tg_id: int) -> bool: + try: + return _EXISTS_CACHE[tg_id] + except KeyError: + pass stmt = select(exists().where(User.tg_id == tg_id)) result = await session.execute(stmt) - return result.scalar() + value = result.scalar() + _EXISTS_CACHE[tg_id] = value + return value async def get_balance(session: AsyncSession, tg_id: int) -> float: @@ -107,6 +122,7 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int): try: await session.execute(update(User).where(User.tg_id == tg_id).values(trial=status)) await session.commit() + invalidate_user_snapshot(tg_id) logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}") except SQLAlchemyError as e: logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}") @@ -158,6 +174,7 @@ async def upsert_user( if row is None: return None await session.commit() + _EXISTS_CACHE[tg_id] = True return dict(row) res = await session.execute( @@ -187,6 +204,7 @@ async def upsert_user( ) row = res.mappings().one() await session.commit() + _EXISTS_CACHE[tg_id] = True return dict(row) except SQLAlchemyError as e: logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}") @@ -222,16 +240,22 @@ async def delete_user_data(session: AsyncSession, tg_id: int): async def mark_trial_extended(tg_id: int, session: AsyncSession): await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1)) await session.commit() + invalidate_user_snapshot(tg_id) async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None: + try: + return _SNAPSHOT_CACHE[tg_id] + except KeyError: + pass 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]) + value = (int(row[0]), int(row[1])) + _SNAPSHOT_CACHE[tg_id] = value + return value async def upsert_source_if_empty( diff --git a/handlers/admin/management/import_remnawave.py b/handlers/admin/management/import_remnawave.py index b7bb1041..c4f4dada 100644 --- a/handlers/admin/management/import_remnawave.py +++ b/handlers/admin/management/import_remnawave.py @@ -26,7 +26,8 @@ def extract_tg_id_from_username(value: str | None) -> int | None: return None value = value.strip() - match = re.search(r"_(\d+)(?:\D|$)", value) + + match = re.search(r"[-_](\d+)(?:\D|$)", value) if not match: return None diff --git a/handlers/admin/sender/sender_service.py b/handlers/admin/sender/sender_service.py index d39d7fb1..cefef810 100644 --- a/handlers/admin/sender/sender_service.py +++ b/handlers/admin/sender/sender_service.py @@ -8,6 +8,7 @@ from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from sqlalchemy.ext.asyncio import AsyncSession +from database import async_session_maker from logger import logger @@ -52,9 +53,14 @@ class RateLimiter: class BroadcastService: - def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None: + def __init__( + self, + bot: Bot, + session: AsyncSession | None = None, + messages_per_second: int = 35, + ) -> None: self.bot = bot - self.session = session + self._session = session self.rate_limiter = RateLimiter(max_rate=messages_per_second) self.blocked_users = set() self.queue = asyncio.Queue() @@ -151,23 +157,31 @@ class BroadcastService: logger.error(f"❌ Ошибка в воркере рассылки: {e}") await asyncio.sleep(0.1) - async def _save_blocked_users(self): + async def _save_blocked_users(self) -> None: if not self.blocked_users: return - try: + async def _do_save(session: AsyncSession) -> None: 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() + await session.execute(stmt) + await session.commit() logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users") + + try: + if self._session is not None: + await _do_save(self._session) + else: + async with async_session_maker() as session: + await _do_save(session) except Exception as e: logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}") - await self.session.rollback() + if self._session is not None: + await self._session.rollback() async def broadcast(self, messages: list[dict], workers: int = 20) -> dict: self.is_running = True diff --git a/handlers/keys/key_create.py b/handlers/keys/key_create.py index 3599ec8b..a603e3be 100644 --- a/handlers/keys/key_create.py +++ b/handlers/keys/key_create.py @@ -408,9 +408,7 @@ async def back_to_subgroup_tariffs(callback: CallbackQuery, state: FSMContext, s gc = tariffs_for_cluster[0].get("group_code") if gc: tariffs = await get_tariffs(session, group_code=gc) - filtered = [ - t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active") - ] + filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")] if not filtered: await back_to_tariff_group_list(callback, state, session) diff --git a/handlers/payments/__init__.py b/handlers/payments/__init__.py index 691782cf..f4b666fa 100644 --- a/handlers/payments/__init__.py +++ b/handlers/payments/__init__.py @@ -1,10 +1,22 @@ -__all__ = ("router",) +__all__ = ( + "router", + "create_payment_link", + "register_payment_creator", + "PaymentLinkRequest", + "PaymentLinkResult", +) from aiogram import Router from config import PROVIDERS_ENABLED from handlers.payments.providers import get_providers +from .payment_links import ( + PaymentLinkRequest, + PaymentLinkResult, + create_payment_link, + register_payment_creator, +) from .cryptobot import router as cryptobot_router from .fast_payment_flow import router as fast_payment_flow_router from .freekassa.freekassa_pay import router as freekassa_router diff --git a/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so index 1cc97c26..c33a3db2 100644 Binary files a/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so index 0d14d307..03e4ec8f 100644 Binary files a/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so index 72287ff7..4cae25eb 100644 Binary files a/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/freekassa/freekassa_pay.py b/handlers/payments/freekassa/freekassa_pay.py index a1dd823e..a0ea0f1a 100644 --- a/handlers/payments/freekassa/freekassa_pay.py +++ b/handlers/payments/freekassa/freekassa_pay.py @@ -1,353 +1,367 @@ -import hashlib - -from datetime import datetime, timedelta -from typing import Any - -from aiogram import F, Router, types -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from aiogram.utils.keyboard import InlineKeyboardBuilder -from aiohttp import web -from sqlalchemy.ext.asyncio import AsyncSession - -from config import ( - FREEKASSA_SECRET1, - FREEKASSA_SECRET2, - FREEKASSA_SHOP_ID, -) -from database import ( - add_payment, - add_user, - async_session_maker, - check_user_exists, - clear_temporary_data, - get_key_count, - get_payment_by_payment_id, - get_temporary_data, - update_balance, -) -from handlers.buttons import BACK, PAY_2 -from handlers.payments.utils import send_payment_success_notification -from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS -from handlers.utils import edit_or_send_message -from logger import logger - - -router = Router() - - -class ReplenishBalanceState(StatesGroup): - choosing_amount_freekassa = State() - waiting_for_payment_confirmation_freekassa = State() - - -def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str: - signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}" - signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() - logger.debug(f"Generated signature for order {order_id}: {signature}") - return signature - - -def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str: - signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency) - - payment_url = "https://pay.fk.money/" - params = { - "m": FREEKASSA_SHOP_ID, - "oa": amount, - "currency": currency, - "o": order_id, - "s": signature, - "us_tg_id": tg_id, - } - - query_string = "&".join([f"{key}={value}" for key, value in params.items()]) - full_url = f"{payment_url}?{query_string}" - - logger.info(f"Generated Freekassa payment link: {full_url}") - return full_url - - -@router.callback_query(F.data == "pay_freekassa") -async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any): - tg_id = callback_query.message.chat.id - logger.info(f"User {tg_id} initiated Freekassa payment.") - - builder = InlineKeyboardBuilder() - for i in range(0, len(PAYMENT_OPTIONS), 2): - if i + 1 < len(PAYMENT_OPTIONS): - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", - ), - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}", - ), - ) - else: - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", - ) - ) - builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) - - key_count = await get_key_count(session, tg_id) - - if key_count == 0: - exists = await check_user_exists(session, tg_id) - if not exists: - from_user = callback_query.from_user - await add_user( - tg_id=from_user.id, - username=from_user.username, - first_name=from_user.first_name, - last_name=from_user.last_name, - language_code=from_user.language_code, - is_bot=from_user.is_bot, - session=session, - ) - logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.") - - await callback_query.message.delete() - - new_message = await callback_query.message.answer( - text="Выберите сумму пополнения:", - reply_markup=builder.as_markup(), - ) - await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id) - await state.set_state(ReplenishBalanceState.choosing_amount_freekassa) - logger.info(f"Displayed amount selection for user {tg_id}.") - - -@router.callback_query(F.data.startswith("freekassa_amount|")) -async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): - logger.info(f"Получены данные callback_data: {callback_query.data}") - - data = callback_query.data.split("|") - if len(data) != 3 or data[1] != "amount": - logger.error("Ошибка: callback_data не соответствует формату.") - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: данные повреждены.", - reply_markup=types.InlineKeyboardMarkup(), - ) - return - - amount_str = data[2] - try: - amount = float(amount_str) - if amount <= 0: - raise ValueError("Сумма должна быть положительным числом.") - except ValueError as e: - logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}") - await edit_or_send_message( - target_message=callback_query.message, - text="Некорректная сумма.", - reply_markup=types.InlineKeyboardMarkup(), - ) - return - - await state.update_data(amount=amount) - logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.") - - tg_id = callback_query.message.chat.id - order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" - - payment_url = generate_payment_link(amount, order_id, tg_id) - - logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}") - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")], - ] - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - ) - logger.info(f"Payment link sent to user {callback_query.message.chat.id}.") - - -def verify_signature(params: dict) -> bool: - try: - merchant_id = params.get("MERCHANT_ID", "") - amount = params.get("AMOUNT", "") - merchant_order_id = params.get("MERCHANT_ORDER_ID", "") - sign = params.get("SIGN", "") - - signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}" - expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() - - logger.debug(f"Signature verification: expected={expected_signature}, received={sign}") - - return expected_signature == sign - except Exception as e: - logger.error(f"Error verifying signature: {e}") - return False - - -async def freekassa_webhook(request: web.Request): - try: - params = dict(request.query) - logger.info(f"Received Freekassa webhook: {params}") - - merchant_id = params.get("MERCHANT_ID") - amount = params.get("AMOUNT") - merchant_order_id = params.get("MERCHANT_ORDER_ID") - sign = params.get("SIGN") - tg_id = params.get("us_tg_id") - - if not all([merchant_id, amount, merchant_order_id, sign]): - logger.error("Missing required parameters in webhook") - return web.Response(status=400, text="Missing required parameters") - - if not verify_signature(params): - logger.error("Invalid signature in webhook") - return web.Response(status=400, text="Invalid signature") - - if str(merchant_id) != str(FREEKASSA_SHOP_ID): - logger.error(f"Invalid merchant_id: {merchant_id}") - return web.Response(status=400, text="Invalid merchant_id") - - try: - amount_float = float(amount) - if tg_id: - tg_id_int = int(tg_id) - else: - order_parts = merchant_order_id.split("_") - if len(order_parts) >= 3 and order_parts[0] == "order": - tg_id_int = int(order_parts[1]) - else: - logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}") - return web.Response(status=400, text="Cannot identify user") - except (ValueError, TypeError) as e: - logger.error(f"Error parsing parameters: {e}") - return web.Response(status=400, text="Invalid parameter format") - - async with async_session_maker() as session: - - existing = await get_payment_by_payment_id(session, merchant_order_id) - if existing and existing.get("status") == "success": - logger.warning( - f"[Freekassa] Повторный webhook. Платёж уже обработан: order_id={merchant_order_id}" - ) - return web.Response(text="YES") - - await update_balance(session, tg_id_int, amount_float) - await send_payment_success_notification(tg_id_int, amount_float, session) - await add_payment( - session, tg_id_int, amount_float, "freekassa", payment_id=merchant_order_id - ) - await clear_temporary_data(session, tg_id_int) - - logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}") - return web.Response(text="YES") - - except Exception as e: - logger.error(f"Error processing Freekassa webhook: {e}") - return web.Response(status=500, text="Internal server error") - - -@router.callback_query(F.data == "enter_custom_amount_freekassa") -async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): - tg_id = callback_query.message.chat.id - logger.info(f"User {tg_id} chose to enter a custom amount.") - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) - - await edit_or_send_message( - target_message=callback_query.message, - text=ENTER_SUM, - reply_markup=builder.as_markup(), - ) - - await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) - - -@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) -async def handle_custom_amount_input( - message: types.Message | types.CallbackQuery, - state: FSMContext = None, - session: AsyncSession = None, -): - if isinstance(message, types.CallbackQuery): - tg_id = message.message.chat.id - target_message = message.message - else: - tg_id = message.chat.id - target_message = message - - logger.info(f"User {tg_id} initiated payment through Freekassa") - - try: - user_data = await get_temporary_data(session, tg_id) - - if not user_data: - await edit_or_send_message( - target_message=target_message, - text="Данные для оплаты не найдены. Попробуйте снова.", - reply_markup=types.InlineKeyboardMarkup(), - ) - return - - state_type = user_data["state"] - amount = user_data["data"].get("required_amount", 0) - - if amount <= 0: - await edit_or_send_message( - target_message=target_message, - text="Недостаточная сумма для пополнения.", - reply_markup=types.InlineKeyboardMarkup(), - ) - return - - order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" - payment_url = generate_payment_link(amount, order_id, tg_id) - logger.info(f"Generated payment link for user {tg_id}: {payment_url}") - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url)) - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) - - if state_type == "waiting_for_payment": - message_text = ( - f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" - ) - elif state_type == "waiting_for_renewal_payment": - message_text = ( - f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" - ) - else: - await edit_or_send_message( - target_message=target_message, - text="Некорректное состояние данных. Попробуйте снова.", - reply_markup=types.InlineKeyboardMarkup(), - ) - return - - await edit_or_send_message( - target_message=target_message, - text=message_text, - reply_markup=builder.as_markup(), - ) - - if isinstance(state, FSMContext): - await state.clear() - - except Exception as e: - logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=target_message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=types.InlineKeyboardMarkup(), - ) +import hashlib + +from datetime import datetime, timedelta +from typing import Any + +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ( + FREEKASSA_SECRET1, + FREEKASSA_SECRET2, + FREEKASSA_SHOP_ID, +) +from database import ( + add_payment, + add_user, + async_session_maker, + check_user_exists, + clear_temporary_data, + get_key_count, + get_payment_by_payment_id, + get_temporary_data, + update_balance, +) +from handlers.buttons import BACK, PAY_2 +from handlers.payments.payment_links import register_payment_creator +from handlers.payments.utils import send_payment_success_notification +from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS +from handlers.utils import edit_or_send_message +from logger import logger + + +router = Router() + + +class ReplenishBalanceState(StatesGroup): + choosing_amount_freekassa = State() + waiting_for_payment_confirmation_freekassa = State() + + +def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str: + signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}" + signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() + logger.debug(f"Generated signature for order {order_id}: {signature}") + return signature + + +def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str: + signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency) + + payment_url = "https://pay.fk.money/" + params = { + "m": FREEKASSA_SHOP_ID, + "oa": amount, + "currency": currency, + "o": order_id, + "s": signature, + "us_tg_id": tg_id, + } + + query_string = "&".join([f"{key}={value}" for key, value in params.items()]) + full_url = f"{payment_url}?{query_string}" + + logger.info(f"Generated Freekassa payment link: {full_url}") + return full_url + + +@router.callback_query(F.data == "pay_freekassa") +async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any): + tg_id = callback_query.message.chat.id + logger.info(f"User {tg_id} initiated Freekassa payment.") + + builder = InlineKeyboardBuilder() + for i in range(0, len(PAYMENT_OPTIONS), 2): + if i + 1 < len(PAYMENT_OPTIONS): + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}", + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", + ) + ) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + key_count = await get_key_count(session, tg_id) + + if key_count == 0: + exists = await check_user_exists(session, tg_id) + if not exists: + from_user = callback_query.from_user + await add_user( + tg_id=from_user.id, + username=from_user.username, + first_name=from_user.first_name, + last_name=from_user.last_name, + language_code=from_user.language_code, + is_bot=from_user.is_bot, + session=session, + ) + logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.") + + await callback_query.message.delete() + + new_message = await callback_query.message.answer( + text="Выберите сумму пополнения:", + reply_markup=builder.as_markup(), + ) + await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id) + await state.set_state(ReplenishBalanceState.choosing_amount_freekassa) + logger.info(f"Displayed amount selection for user {tg_id}.") + + +@router.callback_query(F.data.startswith("freekassa_amount|")) +async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): + logger.info(f"Получены данные callback_data: {callback_query.data}") + + data = callback_query.data.split("|") + if len(data) != 3 or data[1] != "amount": + logger.error("Ошибка: callback_data не соответствует формату.") + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: данные повреждены.", + reply_markup=types.InlineKeyboardMarkup(), + ) + return + + amount_str = data[2] + try: + amount = float(amount_str) + if amount <= 0: + raise ValueError("Сумма должна быть положительным числом.") + except ValueError as e: + logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}") + await edit_or_send_message( + target_message=callback_query.message, + text="Некорректная сумма.", + reply_markup=types.InlineKeyboardMarkup(), + ) + return + + await state.update_data(amount=amount) + logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.") + + tg_id = callback_query.message.chat.id + order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" + + payment_url = generate_payment_link(amount, order_id, tg_id) + + logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}") + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")], + ] + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + ) + logger.info(f"Payment link sent to user {callback_query.message.chat.id}.") + + +def verify_signature(params: dict) -> bool: + try: + merchant_id = params.get("MERCHANT_ID", "") + amount = params.get("AMOUNT", "") + merchant_order_id = params.get("MERCHANT_ORDER_ID", "") + sign = params.get("SIGN", "") + + signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}" + expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() + + logger.debug(f"Signature verification: expected={expected_signature}, received={sign}") + + return expected_signature == sign + except Exception as e: + logger.error(f"Error verifying signature: {e}") + return False + + +async def freekassa_webhook(request: web.Request): + try: + params = dict(request.query) + logger.info(f"Received Freekassa webhook: {params}") + + merchant_id = params.get("MERCHANT_ID") + amount = params.get("AMOUNT") + merchant_order_id = params.get("MERCHANT_ORDER_ID") + sign = params.get("SIGN") + tg_id = params.get("us_tg_id") + + if not all([merchant_id, amount, merchant_order_id, sign]): + logger.error("Missing required parameters in webhook") + return web.Response(status=400, text="Missing required parameters") + + if not verify_signature(params): + logger.error("Invalid signature in webhook") + return web.Response(status=400, text="Invalid signature") + + if str(merchant_id) != str(FREEKASSA_SHOP_ID): + logger.error(f"Invalid merchant_id: {merchant_id}") + return web.Response(status=400, text="Invalid merchant_id") + + try: + amount_float = float(amount) + if tg_id: + tg_id_int = int(tg_id) + else: + order_parts = merchant_order_id.split("_") + if len(order_parts) >= 3 and order_parts[0] == "order": + tg_id_int = int(order_parts[1]) + else: + logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}") + return web.Response(status=400, text="Cannot identify user") + except (ValueError, TypeError) as e: + logger.error(f"Error parsing parameters: {e}") + return web.Response(status=400, text="Invalid parameter format") + + async with async_session_maker() as session: + existing = await get_payment_by_payment_id(session, merchant_order_id) + if existing and existing.get("status") == "success": + logger.warning(f"[Freekassa] Повторный webhook. Платёж уже обработан: order_id={merchant_order_id}") + return web.Response(text="YES") + + await update_balance(session, tg_id_int, amount_float) + await send_payment_success_notification(tg_id_int, amount_float, session) + await add_payment(session, tg_id_int, amount_float, "freekassa", payment_id=merchant_order_id) + await clear_temporary_data(session, tg_id_int) + + logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}") + return web.Response(text="YES") + + except Exception as e: + logger.error(f"Error processing Freekassa webhook: {e}") + return web.Response(status=500, text="Internal server error") + + +@router.callback_query(F.data == "enter_custom_amount_freekassa") +async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): + tg_id = callback_query.message.chat.id + logger.info(f"User {tg_id} chose to enter a custom amount.") + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) + + await edit_or_send_message( + target_message=callback_query.message, + text=ENTER_SUM, + reply_markup=builder.as_markup(), + ) + + await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) + + +@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) +async def handle_custom_amount_input( + message: types.Message | types.CallbackQuery, + state: FSMContext = None, + session: AsyncSession = None, +): + if isinstance(message, types.CallbackQuery): + tg_id = message.message.chat.id + target_message = message.message + else: + tg_id = message.chat.id + target_message = message + + logger.info(f"User {tg_id} initiated payment through Freekassa") + + try: + user_data = await get_temporary_data(session, tg_id) + + if not user_data: + await edit_or_send_message( + target_message=target_message, + text="Данные для оплаты не найдены. Попробуйте снова.", + reply_markup=types.InlineKeyboardMarkup(), + ) + return + + state_type = user_data["state"] + amount = user_data["data"].get("required_amount", 0) + + if amount <= 0: + await edit_or_send_message( + target_message=target_message, + text="Недостаточная сумма для пополнения.", + reply_markup=types.InlineKeyboardMarkup(), + ) + return + + order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" + payment_url = generate_payment_link(amount, order_id, tg_id) + logger.info(f"Generated payment link for user {tg_id}: {payment_url}") + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url)) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) + + if state_type == "waiting_for_payment": + message_text = ( + f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" + ) + elif state_type == "waiting_for_renewal_payment": + message_text = ( + f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" + ) + else: + await edit_or_send_message( + target_message=target_message, + text="Некорректное состояние данных. Попробуйте снова.", + reply_markup=types.InlineKeyboardMarkup(), + ) + return + + await edit_or_send_message( + target_message=target_message, + text=message_text, + reply_markup=builder.as_markup(), + ) + + if isinstance(state, FSMContext): + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}") + await edit_or_send_message( + target_message=target_message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=types.InlineKeyboardMarkup(), + ) + + +async def create_link( + session: AsyncSession, + tg_id: int, + amount: float, + currency: str, + success_url: str | None, + failure_url: str | None, +) -> tuple[str, str]: + if currency not in ("RUB", "USD"): + raise ValueError("Freekassa поддерживает только RUB или USD") + order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" + url = generate_payment_link(amount, order_id, tg_id, currency) + return (url, order_id) + + +register_payment_creator("FREEKASSA", create_link) diff --git a/handlers/payments/heleket/service.py b/handlers/payments/heleket/service.py index e64d5270..7b9e9c6b 100644 --- a/handlers/payments/heleket/service.py +++ b/handlers/payments/heleket/service.py @@ -30,6 +30,7 @@ from handlers.payments.currency_rates import ( pick_currency, to_rub, ) +from handlers.payments.payment_links import register_payment_creator from handlers.payments.keyboards import ( build_amounts_keyboard, parse_amount_from_callback, @@ -420,3 +421,26 @@ async def generate_heleket_payment_link( except Exception as e: logger.error(f"Error creating Heleket payment: {e}") return "https://heleket.com/" + + +async def create_link( + session: AsyncSession, + tg_id: int, + amount: float, + currency: str, + success_url: str | None, + failure_url: str | None, +) -> tuple[str, str | None]: + method = HELEKET_METHODS.get("crypto") + if not method or not method.get("enable"): + raise ValueError("Heleket недоступен") + amount_int = int(amount) + if amount_int < 10: + raise ValueError("Минимальная сумма для Heleket — 10₽") + url = await generate_heleket_payment_link(amount_int, tg_id, method, session) + if not url or url == "https://heleket.com/": + raise ValueError("Не удалось создать платёж Heleket") + return (url, None) + + +register_payment_creator("HELEKET", create_link) diff --git a/handlers/payments/kassai/service.py b/handlers/payments/kassai/service.py index 62360956..9a794540 100644 --- a/handlers/payments/kassai/service.py +++ b/handlers/payments/kassai/service.py @@ -34,6 +34,7 @@ from handlers.payments.keyboards import ( pay_keyboard, payment_options_for_user, ) +from handlers.payments.payment_links import register_payment_creator from handlers.payments.providers import get_providers from handlers.texts import ( ENTER_SUM, @@ -441,3 +442,34 @@ async def generate_kassai_payment_link( except Exception as e: logger.error(f"Error creating KassaAI order: {e}") return "https://fk.life/" + + +def create_link_factory(method_name: str): + async def create_link( + session: AsyncSession, + tg_id: int, + amount: float, + currency: str, + success_url: str | None, + failure_url: str | None, + ) -> tuple[str, str | None]: + if currency != "RUB": + raise ValueError("KassaI поддерживает только RUB") + method = KASSAI_METHODS.get(method_name) + if not method or not method.get("enable"): + raise ValueError("Способ оплаты KassaI недоступен") + amount_int = int(amount) + if method_name == "cards" and amount_int < 50: + raise ValueError("Минимальная сумма для карт — 50₽") + if method_name == "sbp" and amount_int < 10: + raise ValueError("Минимальная сумма для СБП — 10₽") + url = await generate_kassai_payment_link(amount_int, tg_id, method, session) + if not url or url == "https://fk.life/": + raise ValueError("Не удалось создать платёж KassaI") + return (url, None) + + return create_link + + +register_payment_creator("KASSAI_CARDS", create_link_factory("cards")) +register_payment_creator("KASSAI_SBP", create_link_factory("sbp")) diff --git a/handlers/payments/kassai/webhook.py b/handlers/payments/kassai/webhook.py index 2cb182dc..6c1f6dcb 100644 --- a/handlers/payments/kassai/webhook.py +++ b/handlers/payments/kassai/webhook.py @@ -77,9 +77,7 @@ async def kassai_webhook(request: web.Request): if payment.get("status") == "success": logger.info(f"KassaAI: платёж {order_id} уже обработан") return web.Response(text=KASSAI_WEBHOOK_RESPONSE) - ok = await update_payment_status( - session=session, internal_id=int(payment["id"]), new_status="success" - ) + ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success") if not ok: logger.error(f"KassaAI: не удалось обновить статус платежа {order_id}") return web.Response(status=500) diff --git a/handlers/payments/payment_links.py b/handlers/payments/payment_links.py new file mode 100644 index 00000000..f4e0c0f3 --- /dev/null +++ b/handlers/payments/payment_links.py @@ -0,0 +1,89 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from hooks.hooks import run_hooks +from logger import logger +from sqlalchemy.ext.asyncio import AsyncSession + + +@dataclass(frozen=True) +class PaymentLinkRequest: + tg_id: int + amount: int | float + currency: str + provider_id: str + success_url: str | None = None + failure_url: str | None = None + metadata: dict[str, Any] | None = None + + +@dataclass +class PaymentLinkResult: + success: bool + payment_id: str | None = None + payment_url: str | None = None + error: str | None = None + + +PaymentLinkCreator = Callable[ + [AsyncSession, int, float, str, str | None, str | None], + Awaitable[tuple[str, str | None]], +] + +_registry: dict[str, PaymentLinkCreator] = {} + + +def register_payment_creator(provider_id: str, creator: PaymentLinkCreator) -> None: + """Регистрирует создателя платёжной ссылки для кассы.""" + key = provider_id.strip().upper() + _registry[key] = creator + logger.debug(f"[Payments] Зарегистрирован создатель ссылки: {key}") + + +async def merge_creators_from_hooks() -> None: + """Подтягивает создателей из хука payment_register_creators в реестр.""" + results = await run_hooks("payment_register_creators") + for item in results: + if isinstance(item, dict): + for pid, creator in item.items(): + if pid and callable(creator): + key = str(pid).strip().upper() + _registry[key] = creator + + +async def create_payment_link( + session: AsyncSession, + request: PaymentLinkRequest, +) -> PaymentLinkResult: + """Формирует платёжную ссылку через зарегистрированную кассу.""" + await merge_creators_from_hooks() + provider_key = request.provider_id.strip().upper() + creator = _registry.get(provider_key) + if not creator: + return PaymentLinkResult( + success=False, + error=f"Провайдер не найден или не поддерживает ссылку: {provider_key}", + ) + try: + amount = float(request.amount) + except (TypeError, ValueError): + return PaymentLinkResult(success=False, error="Некорректная сумма") + if amount <= 0: + return PaymentLinkResult(success=False, error="Сумма должна быть больше нуля") + currency = (request.currency or "RUB").strip().upper() + try: + url, payment_id = await creator( + session, + request.tg_id, + amount, + currency, + request.success_url, + request.failure_url, + ) + return PaymentLinkResult(success=True, payment_url=url, payment_id=payment_id) + except ValueError as e: + return PaymentLinkResult(success=False, error=str(e)) + except Exception as e: + logger.exception(f"[Payments] Ошибка создания ссылки для {provider_key}: {e}") + return PaymentLinkResult(success=False, error="Ошибка при создании платёжной ссылки") diff --git a/handlers/payments/robokassa/service.py b/handlers/payments/robokassa/service.py index 6bb44f71..3c1525a6 100644 --- a/handlers/payments/robokassa/service.py +++ b/handlers/payments/robokassa/service.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE from database import add_payment +from handlers.payments.payment_links import register_payment_creator def _build_receipt(amount: float, sno: str = "usn_income") -> dict: @@ -85,3 +86,21 @@ def check_payment_signature(params) -> bool: base = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}{shp_suffix}" expected_sig = hashlib.md5(base.encode("utf-8")).hexdigest().upper() return received_sig == expected_sig + + +async def create_link( + session: AsyncSession, + tg_id: int, + amount: float, + currency: str, + success_url: str | None, + failure_url: str | None, +) -> tuple[str, str]: + if currency != "RUB": + raise ValueError("Robokassa поддерживает только RUB") + amount_val = int(amount) if amount == int(amount) else amount + url, pid = await create_and_store_robokassa_payment(session, tg_id, amount_val, "Пополнение баланса", inv_id=0) + return (url, pid) + + +register_payment_creator("ROBOKASSA", create_link) diff --git a/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so index f44fef13..ee809aec 100644 Binary files a/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so b/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so index d02e22ec..26df3eb6 100644 Binary files a/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so index 251b455d..9eeb5653 100644 Binary files a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so index 0e3bd492..e51eb427 100644 Binary files a/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so index 59748bea..f005f4ef 100644 Binary files a/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so index 6e1ae0e1..551e4d8e 100644 Binary files a/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so index c3cf9a82..8383cc89 100644 Binary files a/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so index 3e27dcc8..9d7903cd 100644 Binary files a/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so index f664d022..7e697188 100644 Binary files a/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so index c6a27872..2b0b972e 100644 Binary files a/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so index 893d940b..81bde4d0 100644 Binary files a/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/profile.py b/handlers/profile.py index c9c17e28..c5827b56 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -73,9 +73,7 @@ async def process_callback_view_profile( balance_rub, getattr(user, "language_code", None), ) - profile_menu_buttons = await run_hooks( - "profile_menu", chat_id=chat_id, admin=admin, session=session - ) + profile_menu_buttons = await run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session) text_hooks = await run_hooks( "profile_text", username=username, diff --git a/handlers/start.py b/handlers/start.py index 62a54dd3..1313e6d7 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -60,6 +60,7 @@ from handlers.texts import ( from hooks.hook_buttons import insert_hook_buttons from hooks.hooks import run_hooks from logger import logger +from middlewares.session import release_session_early from .admin.panel.keyboard import AdminPanelCallback from .refferal import handle_referral_link @@ -320,10 +321,11 @@ async def show_start_menu( module_buttons = await run_hooks("start_menu", chat_id=message.chat.id, session=session) kb = insert_hook_buttons(kb, module_buttons) except Exception as e: - logger.error(f"[Hooks:start_menu] Ошибка вставки кнопок: {e}", exc_info=True) + logger.error(f"[Hooks:start_menu] Ошибка вставки кнопов: {e}", exc_info=True) kb.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn")) + await release_session_early(session) await edit_or_send_message(message, WELCOME_TEXT, reply_markup=kb.as_markup(), media_path=image_path) diff --git a/handlers/tariffs/buy/key_tariffs.py b/handlers/tariffs/buy/key_tariffs.py index 9b1b3473..dfd670aa 100644 --- a/handlers/tariffs/buy/key_tariffs.py +++ b/handlers/tariffs/buy/key_tariffs.py @@ -508,11 +508,7 @@ async def render_user_config_screen( is_renew_mode = data.get("renew_mode") == "renew" confirm_prefix = "cfg_renew_confirm" if is_renew_mode else "cfg_user_confirm" - back_callback = ( - "back_to_subgroup_tariffs" - if data.get("tariff_subgroup_hash") - else "back_to_tariff_group_list" - ) + back_callback = "back_to_subgroup_tariffs" if data.get("tariff_subgroup_hash") else "back_to_tariff_group_list" builder.row( InlineKeyboardButton( text=CONFIG_PAY_BUTTON_TEXT.format(amount=price_text), diff --git a/middlewares/admin.py b/middlewares/admin.py index b568f2d7..bf34f896 100644 --- a/middlewares/admin.py +++ b/middlewares/admin.py @@ -3,6 +3,7 @@ from typing import Any from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject +from cachetools import TTLCache from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,8 +11,12 @@ from config import ADMIN_ID from database.models import Admin +_ADMIN_CACHE_TTL = 60 +_admin_cache: TTLCache[int, bool] = TTLCache(maxsize=10_000, ttl=_ADMIN_CACHE_TTL) + + class AdminMiddleware(BaseMiddleware): - """Проверяет, является ли пользователь администратором.""" + """Проверяет, является ли пользователь администратором. Сессию не создаёт — только data['session'].""" _admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID} @@ -50,10 +55,16 @@ class AdminMiddleware(BaseMiddleware): if user_id in self._admin_ids: return True + if user_id in _admin_cache: + return _admin_cache[user_id] + if not session: + _admin_cache[user_id] = False return False result = await session.execute(select(Admin).where(Admin.tg_id == user_id)) - return result.scalar_one_or_none() is not None + is_admin = result.scalar_one_or_none() is not None + _admin_cache[user_id] = is_admin + return is_admin except Exception: return False diff --git a/middlewares/ban_checker.py b/middlewares/ban_checker.py index 5862b2f1..f395cc55 100644 --- a/middlewares/ban_checker.py +++ b/middlewares/ban_checker.py @@ -4,6 +4,7 @@ from typing import Any from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject, Update +from cachetools import TTLCache from pytz import timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,12 +16,11 @@ from logger import logger TZ = timezone("Europe/Moscow") _BAN_CACHE_TTL = 30 -_ban_cache: dict[int, tuple[float, dict | None]] = {} +_ban_cache: TTLCache[int, tuple[float, dict | None]] = TTLCache(maxsize=50_000, ttl=_BAN_CACHE_TTL) class BanCheckerMiddleware(BaseMiddleware): - def __init__(self, session_factory: Callable[[], AsyncSession] | None = None) -> None: - self.session_factory = session_factory + """Проверка банов.""" async def __call__( self, diff --git a/middlewares/direct_start_blocker.py b/middlewares/direct_start_blocker.py index d597ed35..e213e946 100644 --- a/middlewares/direct_start_blocker.py +++ b/middlewares/direct_start_blocker.py @@ -1,10 +1,10 @@ import time - from collections.abc import Awaitable, Callable from typing import Any from aiogram import BaseMiddleware from aiogram.types import Message, Update +from cachetools import TTLCache from sqlalchemy.ext.asyncio import AsyncSession from config import ADMIN_ID, DISABLE_DIRECT_START @@ -14,7 +14,7 @@ from logger import logger _TTL = 20 -_cache_user_exists: dict[int, tuple[float, bool]] = {} +_cache_user_exists: TTLCache[int, tuple[float, bool]] = TTLCache(maxsize=50_000, ttl=_TTL) class DirectStartBlockerMiddleware(BaseMiddleware): @@ -63,7 +63,7 @@ class DirectStartBlockerMiddleware(BaseMiddleware): return True cached = _cache_user_exists.get(tg_id) - if cached and cached[0] > now: + if cached is not None and cached[0] > now: return cached[1] exists = await check_user_exists(session, tg_id) diff --git a/middlewares/session.py b/middlewares/session.py index fbafd539..d2ee5112 100644 --- a/middlewares/session.py +++ b/middlewares/session.py @@ -1,14 +1,74 @@ +import time +from typing import Any + from aiogram import BaseMiddleware +from sqlalchemy.ext.asyncio import AsyncSession from logger import logger +try: + from config import LOG_SESSION_DURATION +except ImportError: + LOG_SESSION_DURATION = False + + +async def release_session_early(session: Any) -> bool: + if hasattr(session, "release_early"): + return await session.release_early() + return False + + +class _SessionProxy: + __slots__ = ("_session", "_maker", "_released", "_data") + + def __init__(self, session: AsyncSession, maker, data: dict) -> None: + self._session = session + self._maker = maker + self._released = False + self._data = data + + async def release_early(self) -> bool: + if self._released: + return False + self._released = True + try: + await self._session.commit() + except Exception: + await self._session.rollback() + try: + await self._session.close() + except Exception: + pass + self._session = None + self._data["_session_released_early"] = True + return True + + async def _with_short_session(self, method: str, *args, **kwargs): + import asyncio + + async with self._maker() as s: + result = getattr(s, method)(*args, **kwargs) + if asyncio.iscoroutine(result): + return await result + return result + + def __getattr__(self, name: str): + if name in ("_session", "_maker", "_released", "_data", "release_early", "_with_short_session"): + raise AttributeError(name) + if self._released: + + def _short(*a, **k): + return self._with_short_session(name, *a, **k) + + return _short + return getattr(self._session, name) + class SessionMiddleware(BaseMiddleware): def __init__(self, sessionmaker): self.sessionmaker = sessionmaker - async def _rollback(self, session, context: str) -> None: - """Attempt rollback so invalid transaction is cleared; log if rollback fails.""" + async def _rollback(self, session: AsyncSession, context: str) -> None: try: await session.rollback() except Exception as rollback_err: @@ -24,47 +84,57 @@ class SessionMiddleware(BaseMiddleware): if data.get("session"): return await handler(event, data) - session = self.sessionmaker() - data["session"] = session - committed = False handler_name = getattr(handler, "__qualname__", getattr(handler, "__name__", str(handler))) event_type = type(event).__name__ + t0 = time.perf_counter() if LOG_SESSION_DURATION else None - try: - result = await handler(event, data) + async with self.sessionmaker() as session: + proxy = _SessionProxy(session, self.sessionmaker, data) + data["session"] = proxy + committed = False try: - await session.commit() - committed = True - return result - except Exception as commit_err: + result = await handler(event, data) + if data.get("_session_released_early"): + committed = True + return result + try: + await session.commit() + committed = True + return result + except Exception as commit_err: + logger.warning( + "Session commit failed, rolling back — handler=%s, event=%s, error=%s: %s", + handler_name, + event_type, + type(commit_err).__name__, + commit_err, + exc_info=True, + ) + await self._rollback(session, "commit failure") + return result + except Exception as e: logger.warning( - "Session commit failed, rolling back (ошибка не пробрасывается) — handler=%s, event=%s, error=%s: %s", + "Session rollback: ошибка при обработке — handler=%s, event=%s, error=%s: %s", handler_name, event_type, - type(commit_err).__name__, - commit_err, + type(e).__name__, + e, exc_info=True, ) - await self._rollback(session, "commit failure") - return result - except Exception as e: - logger.warning( - "Session rollback: ошибка при обработке — handler=%s, event=%s, error=%s: %s", - handler_name, - event_type, - type(e).__name__, - e, - exc_info=True, - ) - await self._rollback(session, "handler failure") - raise - finally: - if not committed: - try: - await session.rollback() - except Exception: - pass - try: - await session.close() - except Exception: - pass + await self._rollback(session, "handler failure") + raise + finally: + if not committed and not data.get("_session_released_early"): + try: + await session.rollback() + except Exception: + pass + if t0 is not None: + duration_ms = int((time.perf_counter() - t0) * 1000) + logger.debug( + "[Session] %s %s handler=%s duration_ms=%d", + event_type, + getattr(event, "update_id", ""), + handler_name, + duration_ms, + ) diff --git a/middlewares/user.py b/middlewares/user.py index fda0b4e7..9c4ea258 100644 --- a/middlewares/user.py +++ b/middlewares/user.py @@ -5,6 +5,7 @@ from typing import Any from aiogram import BaseMiddleware from aiogram.types import TelegramObject, User +from cachetools import TTLCache from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession @@ -14,9 +15,11 @@ from logger import logger class UserMiddleware(BaseMiddleware): - def __init__(self, debounce_sec: float = 60.0) -> None: + def __init__(self, debounce_sec: float = 60.0, cache_maxsize: int = 100_000) -> None: self._debounce = float(debounce_sec) - self._cache: dict[int, tuple[str, float, float, dict | None]] = {} + self._cache: TTLCache[int, tuple[str, float, float, dict | None]] = TTLCache( + maxsize=cache_maxsize, ttl=debounce_sec * 2 + ) async def __call__( self, @@ -75,7 +78,6 @@ class UserMiddleware(BaseMiddleware): obj = res.scalar_one_or_none() if obj is None: return None - await session.commit() d = obj.__dict__.copy() d.pop("_sa_instance_state", None) return d diff --git a/requirements.txt b/requirements.txt index 2395373d..171832df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ MarkupSafe==3.0.2 mdurl==0.1.2 multidict==6.1.0 netaddr==1.3.0 +bcrypt>=4.0.0 pillow==11.3.0 ping3==4.0.8 propcache==0.2.0 diff --git a/utils/telegram_login.py b/utils/telegram_login.py new file mode 100644 index 00000000..ec166d81 --- /dev/null +++ b/utils/telegram_login.py @@ -0,0 +1,35 @@ +import hashlib +import hmac +import time + + +def verify_telegram_login( + payload: dict, + bot_token: str, + *, + max_age_seconds: int = 86400, +) -> bool: + """ + Проверяет подпись и свежесть данных от Telegram Login Widget. + """ + if not payload or not bot_token: + return False + received_hash = payload.get("hash") + if not received_hash: + return False + auth_date = payload.get("auth_date") + if auth_date is None: + return False + try: + if int(auth_date) < time.time() - max_age_seconds: + return False + except (TypeError, ValueError): + return False + + check_parts = sorted((k, v) for k, v in payload.items() if k != "hash" and v is not None) + data_check_string = "\n".join(f"{k}={v}" for k, v in check_parts) + + secret_key = hashlib.sha256(bot_token.encode()).digest() + computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() + + return hmac.compare_digest(computed, received_hash) diff --git a/utils/versioning.py b/utils/versioning.py index 1d701513..b0cfcca3 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"v.5.1 {get_git_commit_number()}" + return f"a20022601 {get_git_commit_number()}"