164 Commits

Author SHA1 Message Date
Vladless f3fc136d1e update yoomoney webhook 2026-03-14 00:20:02 +03:00
Vladless eea908da36 up version and fix negative balance 2026-03-14 00:01:51 +03:00
Capybara-z 04ff996d14 fix update user info 2026-03-13 22:41:48 +03:00
Capybara-z c3dd90a9a9 fix resolve subscription link fallback logic 2026-03-13 02:30:51 +03:00
Vladless 3c8b1fe640 up version 2026-03-02 00:11:39 +03:00
Vladless 0f449680ee Fixed: back button in stars/ traffic display/ trial in country mode. Added error duplicate handling and request queueing. 2026-03-02 00:09:40 +03:00
Vladless 4fa5c882ba yookassa ip adresses 2026-02-25 23:27:58 +03:00
Vladless 8f031d3d1f Handle session commit failure with rollback and do not re-raise 2026-02-14 23:44:23 +03:00
Vladless 534313ce12 admins do not touch the shadow ban 2026-02-14 22:42:17 +03:00
Vladless 586ff2e895 admins do not touch the shadow ban 2026-02-14 22:27:52 +03:00
Vladless 3f62af27b7 indempotence of cash registers/ fixed regime of countries 2026-02-14 22:11:45 +03:00
Vladless bf01c6bcfc chande modules logic 2026-02-14 17:03:19 +03:00
Vladless ec8adb0907 add logs in session middleware 2026-02-14 14:06:05 +03:00
Vladless eafe0c56af working on asynchronous behavior 2026-02-14 13:10:31 +03:00
Vladless b09a71aeaf update api 2026-02-14 13:02:59 +03:00
Vladless dd6ce9ff1d commit in payments 2026-02-13 22:00:12 +03:00
Vladless 90291798eb fix int yokassa 2026-02-13 21:32:28 +03:00
Vladislav Lisitsyn 1d42ab1f04 5.1-Release
5.1-Release
2026-02-12 21:58:58 +03:00
Vladislav Lisitsyn 74b991252a Merge branch 'main' into dev 2026-02-12 21:51:59 +03:00
Vladless f6b3c31d5a up bot version 2026-02-12 21:48:36 +03:00
Vladless 6324426b27 add support for composite callbacks 2026-02-12 20:58:36 +03:00
Capybara-z b58dc6ab82 fix middleware registration 2026-02-12 20:19:19 +03:00
Vladless fc130e86f9 logs in session 2026-02-12 20:00:25 +03:00
Vladless bb63732e91 fix Session in the Sales registers 2026-02-12 15:12:06 +03:00
Vladless a96717bf2a Correction of settings 2026-02-12 01:17:18 +03:00
Vladless a198d1bce0 yookassa receipt 2026-02-12 01:02:47 +03:00
Vladless 2041e66956 database optimization/ back button in the configurator/ query competition and more 2026-02-12 00:53:38 +03:00
Vladless 2f1d8ff9e5 short logs 2026-02-11 20:44:02 +03:00
Vladless 2a68f8e36c fixing a lot of custom emojis 2026-02-11 19:54:17 +03:00
Vladless 472a099a8f ruff format 2026-02-11 03:40:01 +03:00
Vladless 3ca8c0f960 partner api update 2026-02-11 00:28:29 +03:00
Vladless 13fd27aa96 partner color disable 2026-02-10 23:17:00 +03:00
Vladless cc57c8610b custom emoji in inline buttons/ CLI 0.3.9/ updated dependencies/ other minor changes 2026-02-10 22:35:39 +03:00
Vladislav Lisitsyn 3f8db29405 Merge pull request #258 from JustYay/dev
[Feature] WebAPP Open in browser option
2026-02-10 12:12:11 +03:00
JustYay bb8b07c318 webapp open in browser option 2026-02-10 16:51:39 +10:00
Capybara-z 0aa4cf0996 restrict moderator access 2026-02-10 01:51:37 +03:00
Vladless 355f210b8c partner api extension 2026-02-10 01:42:25 +03:00
Vladless b290fc6382 yukassa improvements 2026-02-10 01:32:16 +03:00
Vladless 0cc39b4d7f fixing unresolved characters in the payment response 2026-02-10 01:28:59 +03:00
Vladless 3c00f0cc10 custom emojis/ session optimization/ colored inline buttons 2026-02-10 00:57:30 +03:00
Capybara-z b9c0701fe6 refactor buttons.py layout 2026-02-08 23:54:05 +03:00
Capybara-z 0fd8948870 fix admin key display timezone 2026-02-08 23:10:50 +03:00
Vladless 833721bd60 API 0.5.1/ Minor changes and improvements 2026-02-08 22:30:05 +03:00
Capybara-z 8693115571 fix preserve device/traffic limits on coupon 2026-02-07 22:48:30 +03:00
Capybara-z 7f6f00286c fix improve trial bonus timing / add coupon button toggle 2026-02-07 17:38:15 +03:00
Vladislav Lisitsyn 5556bfc6c7 Merge pull request #257 from JustYay/dev
[FEAUTURE] Изменения прорядка касс через админ-панель
2026-02-07 01:52:09 +03:00
Boris Kovalskii 14b97a9c3d Providers order to admin 2026-02-07 08:36:03 +10:00
Boris Kovalskii 4372e651b9 Provider order to core 2026-02-07 08:34:16 +10:00
Boris Kovalskii 1f47f723d0 Add providers order 2026-02-07 08:33:24 +10:00
Capybara-z e4b79d4410 fix: InaccessibleMessage middleware / bulk restore_trials / auto-renew reset addons 2026-02-06 22:17:52 +03:00
Vladless 0873cdb16d avoid 204 responses for non-telegram webhooks 2026-02-06 00:17:24 +03:00
Vladless 0265e9cb50 all webhooks for middlewares 2026-02-05 22:04:49 +03:00
Vladless 3e1f78f94c webhook_guard 2026-02-05 20:49:49 +03:00
hteppl 81d44d9f7c Optimize IsAdminFilter and prevent checking empty from_user field 2026-02-05 17:34:58 +03:00
Capybara-z 62cde5d6d5 fixing a critical rights vulnerability 2026-02-05 16:58:55 +03:00
Vladless 22d82c9199 fixing a critical rights vulnerability 2026-02-05 16:46:45 +03:00
Capybara-z 84e9637453 fix tariff-server binding 2026-02-04 23:59:18 +03:00
Vladless db16c0d49c interest coupons/ picture for rates/ small improvements 2026-02-04 23:45:34 +03:00
Vladless a967352e9a subscriptions in order 2026-02-04 00:00:50 +03:00
Vladless 93acfa3af9 correcting the display of devices in the configurator 2026-02-01 14:14:24 +03:00
Vladless 8dca92b5d9 the launch script has been changed 2026-02-01 13:10:07 +03:00
Vladless 08f0ab2846 The coupon field has been changed 2026-02-01 12:36:25 +03:00
Vladless 6477afd00f Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More 2026-02-01 09:58:58 +03:00
Vladless 6478495e6e Only SBP for Yookassa/ Pagination in the 'My Subscriptions' menu 2026-01-28 00:07:00 +03:00
Vladless a60405e4f4 fix for invalid characters when creating a yookassa payment 2026-01-25 20:48:17 +03:00
Vladislav Lisitsyn f2a9f81ddf Merge pull request #256 from JustYay/dev
HOTFIX "Консистеность касс V2"
2026-01-25 17:09:24 +03:00
Boris Kovalskii 80bf976fe5 FIX "KassaI && Heleket" 2026-01-25 14:48:35 +10:00
Boris Kovalskii 1b2de84f2d HOTFIX "Консистентность касс"
- исправление edit_or_send_message в кассах
- добавление ожидаемого платежа
- исправление heleket (мусорил в базе)
2026-01-25 14:43:40 +10:00
Vladless 858968665b Optimize user activity updates and fix traffic top-up 2026-01-23 04:22:59 +03:00
Vladless af2d6507da probe and logging changes 2026-01-22 01:24:42 +03:00
Capybara-z 786e2d2288 fix addons config layout / fix selection handling 2026-01-22 00:04:43 +03:00
Vladless 12c1d5badb Added a picture to the renewal menu/ installing module dependencies/ changing the 'my gifts' menu 2026-01-21 23:23:12 +03:00
Vladless 2eeccf99ee fix tribute convertation 2026-01-21 20:47:41 +03:00
Vladless 3eb3fd229e fix delete key and incorrect debit of the balance 2026-01-20 22:58:40 +03:00
Capybara-z d88d1d9482 Add bulk cluster extend / HWID reset on device limit downgrade 2026-01-16 01:06:39 +03:00
Vladless abd0315244 fixing the external squad when reissuing a subscription 2026-01-15 14:27:23 +03:00
Capybara-z 80dc53f272 fix: tariff server binding, VLESS links from API, zero traffic logic 2026-01-14 21:29:07 +03:00
Capybara-z 218a5b544d Add API for managing tariff configurator / Improved version 0.4.0 (Alpha) 2026-01-10 21:11:16 +03:00
Capybara-z 2e25957855 Fix special notifications 2026-01-10 17:05:39 +03:00
Capybara-z f60a5b7506 Refactor special notifications / Refactor server tariff bindings 2026-01-08 23:18:33 +03:00
Capybara-z 316b6359fc Fix delete delay config 2026-01-06 17:26:09 +03:00
Capybara-z a6ed138c09 Notifications refactor / Admin gifts management / Admin menu user improvements / Fix revoke subscription 2026-01-06 16:30:25 +03:00
Capybara-z 9c8b2c8c50 fix use correct tariff price on key renewal / fix auto-renewal notification logic 2026-01-02 15:05:04 +03:00
Vladless 457048579a Fix node online count 2025-12-27 04:10:56 +03:00
Vladless 78afc78ab7 import remnawave users with tg_id parsing from username 2025-12-27 03:19:48 +03:00
Capybara-z e187d1167b add tariff group to key info 2025-12-26 22:26:32 +03:00
Vladless 35e9a6d104 changing the tariff through the admin panel without recreating it 2025-12-26 20:23:50 +03:00
Vladless f32ebc95cd Export user payments to CSV 2025-12-26 18:53:23 +03:00
Vladless 79ddde90ca CLI 0.3.8/update yookassa SDK 2025-12-26 18:22:30 +03:00
Vladless adfadcf127 CLI 0.3.8/update yookassa SDK 2025-12-26 18:16:11 +03:00
Capybara-z 8a0b9b7c98 fix traffic in key view 2025-12-26 16:48:20 +03:00
Capybara-z 9bec6fe341 add configurable backup archive 2025-12-25 00:08:11 +03:00
Vladless ab39653bab fix unlim traffic 2025-12-24 20:17:00 +03:00
Vladless a7e3f302d6 fixed option reset when extending 2025-12-23 21:45:50 +03:00
Vladless 38a8fe251a fix TelegramBadRequest in traffic pricing and null in configurator. Extended description of billing in the settings 2025-12-23 14:41:05 +03:00
Vladless 03763a79f6 bug fixes 2025-12-22 22:09:33 +03:00
Capybara-z bdbd262fce Fix subscription deletion in notifications 2025-12-22 13:44:32 +03:00
Vladless ea87cc0393 fix: overrides in addons pack/ mine in the balance after purchase/ import remnawave 2025-12-22 00:36:08 +03:00
Capybara-z 5f40605e9f fix tariff overrides and renewal limits 2025-12-21 23:31:57 +03:00
Capybara-z 86836da169 refact sync 2025-12-21 16:36:18 +03:00
Capybara-z 92e2e8cb3f Add discount tariff availability checks 2025-12-21 00:07:48 +03:00
Capybara-z f6ca1bda22 fix key config limits on expiry change 2025-12-18 00:16:57 +03:00
Vladless 2025329e5f recalculation during purchase 2025-12-15 23:46:03 +03:00
Vladless e71a20cd3e fixed balance write-off in gifts/ added settings in api 2025-12-15 20:18:19 +03:00
Vladless 20c9b36c26 minor fixes 2025-12-14 22:56:33 +03:00
Capybara-z 5c2bde726c Add revoke subs Remnawave 2025-12-14 22:42:51 +03:00
Capybara-z 7937bceb9c Add configurable webhook response for KassaAI 2025-12-14 21:45:04 +03:00
Capybara-z 5aa762aaf1 add key configuration management 2025-12-14 18:51:00 +03:00
Vladless 3d2269a368 add start_entry hook 2025-12-14 18:36:44 +03:00
Vladless 4f5878bc4e users cache 2025-12-11 16:41:49 +03:00
Vladless 851e452292 All middlewares now work through a single DB session 2025-12-11 15:49:38 +03:00
Vladless 985ae18bfb new version 2025-12-10 20:28:07 +03:00
Vladless 9c999e7220 Cli 0.3.7/ Warning 2025-12-10 16:32:32 +03:00
Vladless 877c005a6f country_mode in database/ payment options 2025-12-10 12:44:27 +03:00
Vladless 1b1780df55 migration between external squads/ make format 2025-12-10 07:34:48 +03:00
Vladless 9bacc0c8e3 add support remnawave 2.3.x 2025-12-09 23:59:31 +03:00
Capybara-z 01dea3cccf fix renew hwid configurator 2025-12-09 21:03:17 +03:00
Vladless 748adc6697 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-08 10:40:32 +03:00
Vladless 919335d858 normalize_expiry_ms 2025-12-08 10:39:14 +03:00
Capybara-z ab4418b503 Tariff configurator for discount tariffs 2025-12-08 01:59:35 +03:00
Vladless 654b20c18f multiple fixes 2025-12-07 23:01:21 +03:00
Vladless 8183c2b678 optimization of the start command 2025-12-07 00:32:43 +03:00
Vladislav Lisitsyn ccacc98b0e Merge pull request #254 from JustYay/dev
FIX "HWID Limit"
2025-12-06 03:32:19 +03:00
Boris Kovalskii 0d11a9b645 FIX "HWID Limit" 2025-12-06 10:27:33 +10:00
Vladless 6a29a7acd4 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-06 01:54:57 +03:00
Vladless ea91d1f7b0 gift rate configurator 2025-12-06 01:54:05 +03:00
Capybara-z ef59eeac4c Refactor admin key display logic 2025-12-04 00:49:01 +03:00
Capybara-z 26da97187c Refactor sender button parsing / Add tariff hooks 2025-12-03 02:26:17 +03:00
Vladless 48ebdeca0f fix tariffs in country mode 2025-12-01 23:13:55 +03:00
Vladless 2b1018d7d3 changing the logic of the fare selection button 2025-12-01 22:06:55 +03:00
Vladislav Lisitsyn 317d905a2a Merge pull request #253 from JustYay/dev
FIX "Создание лимита трафика"
2025-12-01 13:21:56 +03:00
Boris Kovalskii 610ccdd1f9 FIX "Создание лимита трафика"
FIX "Создание лимита трафика" при генерации ключа
2025-12-01 11:44:05 +10:00
Vladless d2288c62a3 fix notification options in settings 2025-11-30 22:43:51 +03:00
Vladless 9f10fed3e7 added support for external squads 2025-11-30 18:37:18 +03:00
Capybara-z 1140a6c050 fix deletion of minute subscriptions 2025-11-30 00:52:01 +03:00
Vladless f2d14d69b0 rate configurator/ purchase of conditions/ fixed payments/ refactoring/ major improvements and fixes 2025-11-29 19:08:01 +03:00
Capybara-z 28cfe91175 fix: show payment statuses in balance operations 2025-11-24 00:06:00 +03:00
Capybara-z 7a985eb9c6 refactor broadcast system with improved speed and flood control 2025-11-20 17:27:54 +03:00
Capybara-z afd99691f6 admin menu hooks 2025-11-18 16:37:25 +03:00
Capybara-z 153be452b0 Fix referral bonus calculation 2025-11-18 10:50:07 +03:00
Vladless 679e65ec44 minor fixes 2025-11-17 00:47:06 +03:00
Capybara-z f44bd9e4b6 Refactor hooks system / Fix country mode special groups / Fix location change filtering 2025-11-16 18:51:59 +03:00
Vladless 0385e15815 freezing subscriptions for admins/ organizing selection in country mode 2025-11-16 05:08:48 +03:00
Vladislav Lisitsyn b109834543 Merge pull request #252 from JustYay/dev
FIX "Консистентный дизайн касс"
2025-11-16 05:04:32 +03:00
Boris Kovalskii 9f353e8b5c FIX "Консистентный дизайн касс"
- FIX "Консистентный дизайн касс" Heleket и Kassai
- Оптимизация касс по PEP8
- Оптимизация функций KassaI
2025-11-16 12:00:19 +10:00
Vladless 8b77ccda9e Adding admin operations to the balance 2025-11-16 03:30:00 +03:00
Capybara-z 1d5c80cd35 fix admin crypto links / fix sender button formatting 2025-11-16 01:15:15 +03:00
Vladless 041e1c898d runtime settings loaded from DB/ refactoring admin-panel/ minor fixes and improvements 2025-11-15 23:14:34 +03:00
Capybara-z 76390df4bc Remove break in hooks / improve ban management 2025-11-14 17:32:41 +03:00
Capybara-z c3941397cf Add hooks extension 2025-11-13 19:10:31 +03:00
Capybara-z 05e8bfece4 fix country mode sync / add cryptolink hook 2025-11-12 16:17:30 +03:00
Vladislav Lisitsyn f6f9fdce18 Merge pull request #251 from TrackLine/dev
Добавление поддержки прямых ссылок на команды
2025-11-12 00:52:35 +03:00
Max Shalenkov 4525769370 fix new urls 2025-11-12 01:50:04 +04:00
Max Shalenkov 60447b41f6 add direct url function 2025-11-11 23:51:06 +04:00
Capybara-z 045fbddecb Merge pull request #250 from TrackLine/dev
remove WATA payments
2025-11-10 22:46:50 +03:00
Max Shalenkov 84d8024d08 remove WATA payments 2025-11-10 23:38:05 +04:00
Capybara-z 617cdbad10 refactor stats menu / fix subscription renewal 2025-11-04 23:55:48 +03:00
Vladless 532f066ffb a small fix for the back button 2025-11-01 23:53:53 +03:00
Capybara-z e29e903efa Fix special tariff notifications 2025-11-01 14:54:27 +03:00
Capybara-z e1602da083 fix sender banning issues 2025-10-25 18:29:12 +03:00
Vladislav Lisitsyn 96b977adbd v4.4 → v.5 2025-10-25 17:13:19 +03:00
Vladless e16f5a50ee update issue template 2025-10-01 14:12:26 +03:00
Vladless 478a6ecd8b add issue template 2025-10-01 14:06:24 +03:00
Vladless 6a3def212c contributing guidelines / code of conduct / security policy / pr template 2025-10-01 13:57:44 +03:00
237 changed files with 25975 additions and 10317 deletions
+3 -3
View File
@@ -43,7 +43,6 @@ database.db
bot_old.py
bot_old_2.py
backup_pg.sh
docker-compose.yml
config copy.py
handlers/texts.py
@@ -53,11 +52,12 @@ Thumbs.db
nginx.conf
scripts
Dockerfile
.csv
/logs
setup.py
.ruff_cache
.github/workflows/
modules/
storage/
storage/
.license_state
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY . .
RUN rm -rf /app/venv \
&& python -m venv /app/venv \
&& /app/venv/bin/pip install --upgrade pip \
&& /app/venv/bin/pip install -r requirements.txt
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app
USER appuser
CMD ["/app/venv/bin/python", "main.py"]
+46 -7
View File
@@ -1,15 +1,50 @@
from fastapi import FastAPI
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc
from time import perf_counter
from fastapi import FastAPI, Request
from api.routes import (
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
)
from config import API_LOGGING
from logger import logger
app = FastAPI(
title="SoloBot API (preAlpha)",
version="0.2.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
title="SoloBot API (Alpha)",
version="0.5.2",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
if not API_LOGGING:
return await call_next(request)
started = perf_counter()
response = await call_next(request)
duration_ms = int((perf_counter() - started) * 1000)
client_ip = request.client.host if request.client else "-"
path_qs = request.url.path
if request.url.query:
path_qs = f"{path_qs}?{request.url.query}"
logger.info(
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
)
return response
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
@@ -17,7 +52,11 @@ app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
app.include_router(misc.router, prefix="/api")
app.include_router(modules.router, prefix="/api")
app.include_router(management.router, prefix="/api/management", tags=["Management"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
@app.get("/api", include_in_schema=False)
+35 -16
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -7,13 +9,23 @@ from api.depends import get_session, verify_admin_token
from database.models import Admin
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
setattr(obj, "vless", False)
def to_schema(schema_response: type, obj: object):
normalize_outgoing_object(obj)
return schema_response.model_validate(obj, from_attributes=True)
def generate_crud_router(
*,
model: type,
@@ -35,7 +47,10 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
return result.scalars().all()
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
if "get_by_email" in enabled_methods and extra_get_by_email:
@@ -49,7 +64,7 @@ def generate_crud_router(
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return obj
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@@ -60,12 +75,12 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@@ -76,52 +91,56 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return objs
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: schema_create, # type: ignore
payload: Any = Body(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = payload.dict(exclude_unset=True)
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return obj
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: schema_update, # type: ignore
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for k, v in payload.dict(exclude_unset=True).items():
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return obj
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@@ -132,7 +151,7 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
+219
View File
@@ -0,0 +1,219 @@
import os
import re
import subprocess
import sys
import asyncio
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from config import API_TOKEN
from core.bootstrap import MANAGEMENT_CONFIG
from core.settings.management_config import update_management_config
from database.models import Key, User
from database.models import Server
from handlers.admin.sender.sender_service import BroadcastService
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
await session.commit()
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
await session.commit()
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
text_raw = (payload.text or "").strip()
if not text_raw:
raise HTTPException(status_code=400, detail="Broadcast text is required")
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
clean_text, keyboard = parse_message_buttons(text_raw)
max_len = 1024 if payload.photo else 4096
if len(clean_text) > max_len:
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
if not tg_ids:
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
bot = _get_broadcast_bot()
messages = [
{
"tg_id": tg_id,
"text": clean_text,
"photo": payload.photo,
"keyboard": keyboard,
}
for tg_id in tg_ids
]
workers = max(1, min(int(payload.workers or 5), 30))
rate = max(1, min(int(payload.messages_per_second or 35), 60))
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
stats = await broadcast_service.broadcast(messages, workers=workers)
return {
"success": True,
"message": "Broadcast completed",
"recipients": total_users,
"stats": stats,
}
-16
View File
@@ -9,7 +9,6 @@ from api.schemas import (
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
@@ -20,7 +19,6 @@ from database.models import (
ManualBan,
Notification,
Payment,
Referral,
TemporaryData,
TrackingSource,
)
@@ -56,20 +54,6 @@ async def get_payments_by_tg_id(
return payments
router.include_router(
generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referred_tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/referrals",
tags=["Referrals"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=Notification,
+124
View File
@@ -0,0 +1,124 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
refresh = getattr(manager, "refresh_state", None)
if callable(refresh):
refresh()
else:
legacy_refresh = getattr(manager, "_load_state", None)
if callable(legacy_refresh):
legacy_refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
local_version = _read_local_module_version(name)
item["local_version"] = local_version
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
+962
View File
@@ -0,0 +1,962 @@
from datetime import datetime
import csv
from io import StringIO
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
"""Normalize percent input to 0-100 range."""
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
def _row_dt_iso(value) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return None
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
partners_list = []
default_percent = _default_partner_percent()
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
if percent_custom and percent_value is not None:
percent = float(percent_value)
else:
percent = float(default_percent)
partners_list.append({
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
})
return JSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"partners_today": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(
SELECT COUNT(DISTINCT partner_tg_id)
FROM partners
WHERE partner_tg_id IS NOT NULL
AND DATE(created_at) = CURRENT_DATE
) AS partners_today,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(
SELECT COALESCE(SUM(u.partner_balance), 0.0)
FROM users u
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"partners_today": int(stats_row[1] or 0),
"total_referred": int(stats_row[2] or 0),
"total_balance": float(stats_row[3] or 0.0),
"top_partner_tg_id": int(stats_row[4] or 0),
"top_partner_refs": int(stats_row[5] or 0),
}
else:
stats = {
"total_partners": 0,
"partners_today": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return JSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
status_code=200,
)
else:
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(
content={"success": False, "message": str(e)},
status_code=500,
)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method
FROM users u
WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value = meta_row[1]
percent_custom = bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return JSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователю партнёра."""
if joined_tg_id == tg_id:
return JSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
status_code=400,
)
try:
partner_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
if not partner_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
if not invited_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"},
status_code=404,
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return JSONResponse(
content={
"success": False,
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
},
status_code=409,
)
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
return JSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text(
"""
DELETE FROM partners
WHERE partner_tg_id = :partner_tg_id
AND joined_tg_id = :joined_tg_id
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return JSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return JSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
status_code=400,
)
try:
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = :percent, partner_percent_custom = true
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id, "percent": normalized},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized},
status_code=200,
)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return JSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
status_code=400,
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Неверная сумма"},
status_code=400,
)
if amount_val < 0:
return JSONResponse(
content={"success": False, "message": "Сумма не может быть отрицательной"},
status_code=400,
)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
current_balance = current_res.scalar()
if current_balance is None:
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return JSONResponse(
content={"success": False, "message": "Недостаточно средств"},
status_code=400,
)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
await session.commit()
return JSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
status_code=200,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return JSONResponse(content=invited_list)
@router.get("/payouts/pending")
async def get_partner_payouts_pending(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список ожидающих заявок на вывод."""
where_clause = "WHERE pr.status = 'pending'"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at ASC, pr.id ASC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "pending",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return JSONResponse(content={"total": int(total), "items": items})
@router.get("/payouts/history")
async def get_partner_payouts_history(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает историю выплат (approved/rejected)."""
where_clause = "WHERE pr.status IN ('approved','rejected')"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at DESC, pr.id DESC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return JSONResponse(content={"total": int(total), "items": items})
@router.post("/payouts/{payout_id}/approve")
async def approve_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Одобряет заявку на вывод."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'approved', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
@router.post("/payouts/{payout_id}/reject")
async def reject_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'rejected', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
if user is not None:
current_balance = float(user[2] or 0.0)
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
@router.patch("/{tg_id}/percent/reset")
async def reset_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает персональный процент партнёра к дефолту."""
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = NULL, partner_percent_custom = false
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.patch("/{tg_id}/code")
async def update_partner_code(
tg_id: int = Path(..., description="Telegram ID партнёра"),
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет код партнёрской ссылки."""
raw = (code or "").strip().lower()
if not raw:
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
import re
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
return JSONResponse(
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
status_code=400,
)
exists = await session.execute(
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if exists.first():
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
result = await session.execute(
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
{"code": raw, "tg_id": tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.post("/reset-disabled-methods")
async def reset_disabled_payout_methods(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
from modules.partner_program import buttons as B
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
ENABLE_PAYOUT_TON = True
ENABLE_PAYOUT_SBP = True
B = None
disabled = []
if not ENABLE_PAYOUT_CARD and B:
disabled.append(B.METHOD_CARD)
if not ENABLE_PAYOUT_USDT and B:
disabled.append(B.METHOD_USDT)
if not ENABLE_PAYOUT_TON and B:
disabled.append(B.METHOD_TON)
if not ENABLE_PAYOUT_SBP and B:
disabled.append(B.METHOD_SBP)
if not disabled:
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
await session.execute(
text(
"""
UPDATE users
SET card_number = NULL
WHERE payout_method = ANY(:methods)
"""
),
{"methods": disabled},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
@router.get("/{tg_id}/export")
async def export_partner_invites_csv(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Экспортирует приглашённых партнёром в CSV."""
rows = await session.execute(
text(
"""
SELECT joined_tg_id, created_at
FROM partners
WHERE partner_tg_id = :tg_id
ORDER BY created_at ASC
"""
),
{"tg_id": tg_id},
)
data = rows.fetchall()
if not data:
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
buffer = StringIO()
writer = csv.writer(buffer, delimiter=";")
writer.writerow(["joined_tg_id", "created_at"])
for joined_tg_id, created_at in data:
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
content = buffer.getvalue().encode("utf-8-sig")
filename = f"partner_invites_{tg_id}.csv"
return StreamingResponse(
iter([content]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+153
View File
@@ -0,0 +1,153 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.schemas.settings import SettingResponse, SettingUpsert
from database.models import Setting
from database.settings import set_setting
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from pydantic import BaseModel
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting))
return result.scalars().all()
@router.get("/configs")
async def get_configs(admin=Depends(verify_admin_token)):
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.commit()
await session.refresh(obj)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
await session.commit()
return {"detail": "Setting deleted"}
+1
View File
@@ -11,5 +11,6 @@ from .misc import (
)
from .referrals import ReferralResponse
from .servers import ServerBase, ServerResponse, ServerUpdate
from .settings import SettingResponse, SettingUpsert
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
+51 -10
View File
@@ -11,15 +11,34 @@ class CouponBase(BaseModel):
is_used: bool = False
days: int | None = Field(default=None)
percent: int | None = Field(default=None)
max_discount_amount: int | None = Field(default=None)
min_order_amount: int | None = Field(default=None)
new_users_only: bool = False
@model_validator(mode="after")
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
def check_coupon_type(self) -> "CouponBase":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided != 1:
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
if has_amount and has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
if not has_amount and not has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
return self
@@ -38,12 +57,34 @@ class CouponUpdate(BaseModel):
is_used: bool | None = None
days: int | None = Field(default=None)
percent: int | None = None
max_discount_amount: int | None = None
min_order_amount: int | None = None
new_users_only: bool | None = None
@model_validator(mode="after")
def validate_amount_or_days(self) -> "CouponUpdate":
if self.amount is None and self.days is None:
return self
if self.amount is not None and self.days is not None:
raise ValueError("Specify only one of: 'amount' or 'days'")
def validate_coupon_update(self) -> "CouponUpdate":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided > 1:
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
+21
View File
@@ -16,6 +16,13 @@ class KeyBase(BaseModel):
notified: bool | None = False
notified_24h: bool | None = False
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class KeyResponse(KeyBase):
class Config:
@@ -41,6 +48,13 @@ class KeyDetailsResponse(BaseModel):
location_name: str | None
tariff_id: int | None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
@@ -57,6 +71,13 @@ class KeyUpdate(BaseModel):
notified: bool | None = None
notified_24h: bool | None = None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class SettingUpsert(BaseModel):
value: Any | None = None
description: str | None = None
class SettingResponse(BaseModel):
key: str
value: Any | None = None
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
+27
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
@@ -13,6 +14,19 @@ class TariffBase(BaseModel):
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool = False
external_squad: str | None = None
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class TariffResponse(TariffBase):
@@ -34,6 +48,19 @@ class TariffUpdate(BaseModel):
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool | None = None
external_squad: str | None = None
configurable: bool | None = None
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class Config:
from_attributes = True
+2 -2
View File
@@ -10,8 +10,8 @@ class UserBase(BaseModel):
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float = 0.0
trial: int = 0
balance: float | None = 0.0
trial: int | None = 0
source_code: str | None = None
+11 -131
View File
@@ -1,20 +1,16 @@
import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import ADMIN_ID, API_TOKEN
from database import async_session_maker
from config import API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
from utils.custom_emojis import initialize_custom_emojis
from utils.errors import setup_error_handlers
from utils.modules_loader import load_modules_from_folder, modules_hub
apply_button_icons_patch()
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
@@ -24,128 +20,12 @@ dp.include_router(modules_hub)
load_modules_from_folder()
from handlers.buttons import BUTTON_ICON_CONFIG
set_button_icon_config(BUTTON_ICON_CONFIG)
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
try:
tb = "".join(
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
)
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
logger.error(f"Traceback:\n{tb}")
if ADMIN_ID:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
tb.encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
)
except Exception as e:
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
try:
from handlers.start import start_entry
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
setup_error_handlers(dp)
initialize_custom_emojis()
Executable → Regular
+176 -82
View File
@@ -58,6 +58,56 @@ BACK_DIR = os.path.expanduser("~/.solobot_backups")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
def is_ascii_only(value: str) -> bool:
"""Проверка, что строка содержит только ASCII."""
return all(ord(ch) < 128 for ch in value)
def _parse_tag_version(tag_name: str) -> tuple[int, ...]:
"""Извлекает кортеж (major, minor, patch, ...) из тега для сортировки. v.5.1 -> (5, 1), v4 -> (4, 0)."""
s = tag_name.strip().lstrip("v.")
parts = []
for part in re.split(r"[.\s]+", s):
try:
parts.append(int(part))
except ValueError:
break
return tuple(parts) if parts else (0,)
def warn_english_only():
"""Предупреждение о необходимости английской раскладки."""
console.print("[red]Обнаружен ввод с неанглийской раскладкой.[/red]")
console.print("[yellow]Пожалуйста, переключите раскладку на ENG и введите снова.[/yellow]")
def safe_confirm(message: str, **kwargs) -> bool:
"""Безопасный Confirm.ask с защитой от русской раскладки."""
while True:
try:
result = Confirm.ask(message, **kwargs)
return result
except UnicodeDecodeError:
warn_english_only()
def safe_prompt(message: str, **kwargs) -> str:
"""Безопасный Prompt.ask с защитой от русской раскладки."""
while True:
try:
value = Prompt.ask(message, **kwargs)
except UnicodeDecodeError:
warn_english_only()
continue
if isinstance(value, str) and not is_ascii_only(value):
warn_english_only()
continue
return value
if IS_ROOT_DIR:
console.print("[bold red]КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
@@ -66,8 +116,6 @@ if IS_ROOT_DIR:
console.print("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
def is_service_exists(service_name):
@@ -154,7 +202,7 @@ def restore_from_backup():
shown.append((idx, path))
try:
choice = Prompt.ask(
choice = safe_prompt(
"[bold blue]Выберите номер бэкапа[/bold blue]",
choices=[str(i) for i, _ in shown],
)
@@ -164,7 +212,7 @@ def restore_from_backup():
sel_path = shown[int(choice) - 1][1]
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
if not safe_confirm("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
return
if is_service_exists(SERVICE_NAME):
@@ -174,7 +222,10 @@ def restore_from_backup():
install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
rc = subprocess.run(
["rsync", "-a", "--delete", f"{sel_path}/", f"{PROJECT_DIR}/"],
check=False,
).returncode
if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return
@@ -203,7 +254,7 @@ def auto_update_cli():
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text)
os.chmod(current_path, 0o755)
os.chmod(current_path, 0o644)
console.print("[green]CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path])
else:
@@ -334,13 +385,13 @@ def install_dependencies():
shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]")
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
subprocess.run([python312_path, "-m", "venv", "venv"], check=True)
progress.update(task_id, description="Установка зависимостей...")
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
[os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"],
check=True,
cwd=PROJECT_DIR,
)
progress.update(task_id, description="Установка завершена")
@@ -354,7 +405,7 @@ def restart_service():
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
@@ -389,21 +440,40 @@ def update_from_beta():
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
console.print(
Panel(
"[bold red]Обновление на DEV / BETA-ветку[/bold red]\n\n"
"[white]"
"• Dev-ветка может содержать изменения, которые ещё находятся в доработке.\n"
"• Возможны ошибки и непредсказуемое поведение отдельных функций, особенно режима стран.\n\n"
"• BETA-версии бота в первую очередь ориентированы на опытных пользователей, "
"готовых протестировать новые возможности и осознанно работать с обновлённым функционалом.\n"
"[/white]\n\n"
"[yellow]Перед началом обновления CLI автоматически создаёт резервную копию проекта, "
"что позволит при необходимости безопасно восстановиться из бэкапа.[/yellow]",
border_style="red",
title="[bold red]Нестабильная ветка разработки[/bold red]",
padding=(1, 2),
)
)
if local_version and remote_version:
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
if not safe_confirm("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
return
if not Confirm.ask("[yellow]Подтвердите обновление Solobot с ветки DEV[/yellow]"):
if not safe_confirm(
"[bold red]Продолжить обновление на dev-ветку с учётом возможных особенностей работы?[/bold red]"
):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]Продолжить обновление?[/red]"):
if not safe_confirm("[red]Продолжить обновление?[/red]"):
return
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
@@ -427,7 +497,8 @@ def update_from_beta():
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
@@ -449,82 +520,105 @@ def update_from_beta():
console.print("[green]Обновление с ветки dev завершено.[/green]")
def _do_update_to_tag(tag_name: str, update_buttons: bool, update_img: bool) -> None:
"""Общая логика обновления до указанного тега (релиз или произвольный тег)."""
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
["git", "clone", "--branch", tag_name, "--depth", "1", GITHUB_REPO, TEMP_DIR],
check=True,
)
console.print("[red]Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print(f"[green]Обновление до {tag_name} завершено.[/green]")
def update_from_release():
if not Confirm.ask("[yellow]Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
if not safe_confirm("[yellow]Подтвердите обновление Solobot до релиза или патча[/yellow]"):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not Confirm.ask("[red]Вы точно хотите продолжить?[/red]"):
if not safe_confirm("[red]Вы точно хотите продолжить?[/red]"):
return
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
try:
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
if not tag_choices:
raise ValueError("Не удалось получить список релизов")
console.print("\n[bold green]Доступные релизы:[/bold green]")
for idx, tag in enumerate(tag_choices, 1):
console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
rel_resp = requests.get(
"https://api.github.com/repos/Vladless/Solo_bot/releases",
timeout=10,
)
tag_name = tag_choices[int(selected) - 1]
releases = rel_resp.json() if rel_resp.status_code == 200 else []
release_tag_names = {r["tag_name"] for r in releases}
if not Confirm.ask(f"[yellow]Подтвердите установку релиза {tag_name}[/yellow]"):
tags_resp = requests.get(
"https://api.github.com/repos/Vladless/Solo_bot/tags",
params={"per_page": 50},
timeout=10,
)
if tags_resp.status_code != 200:
raise ValueError("Не удалось получить список тегов")
tags_data = tags_resp.json()
all_tag_names = [t["name"] for t in tags_data]
tag_names = [name for name in all_tag_names if _parse_tag_version(name)[0] >= 4]
tag_names.sort(key=_parse_tag_version)
if not tag_names:
raise ValueError("Нет доступных тегов (ожидаются версии начиная с 4)")
console.print("\n[bold green]Релизы и патчи:[/bold green]")
for idx, name in enumerate(tag_names, 1):
label = " [dim](релиз)[/dim]" if name in release_tag_names else " [dim](патч)[/dim]"
console.print(f"[cyan]{idx}.[/cyan] {name}{label}")
choices = [str(i) for i in range(1, len(tag_names) + 1)]
selected = safe_prompt(
"[bold blue]Выберите номер версии[/bold blue]",
choices=choices,
)
tag_name = tag_names[int(selected) - 1]
if not safe_confirm(f"[yellow]Установить {tag_name}?[/yellow]"):
return
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
shell=True,
check=True,
)
console.print("[red]Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
console.print(f"[cyan]Клонируем {tag_name} во временную папку...[/cyan]")
_do_update_to_tag(tag_name, update_buttons, update_img)
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
@@ -540,11 +634,11 @@ def show_update_menu():
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
table.add_row("1", "Обновить до BETA")
table.add_row("2", "Обновить/откатить до релиза")
table.add_row("2", "Обновить до релиза (релизы и патчи)")
table.add_row("3", "Назад в меню")
console.print(table)
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
choice = safe_prompt("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
if choice == "1":
update_from_beta()
@@ -553,7 +647,7 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI v0.3.3", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.4.0", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
@@ -575,7 +669,7 @@ def main():
try:
while True:
show_menu()
choice = Prompt.ask(
choice = safe_prompt(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 10)],
show_choices=False,
@@ -586,17 +680,17 @@ def main():
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
if safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
if safe_confirm("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "4":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
if safe_confirm("[red]Вы уверены, что хотите остановить бота?[/red]"):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
from database import async_session_maker
from database.db import warm_pool
from database.tariffs import initialize_all_tariff_weights
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
async def bootstrap() -> None:
await warm_pool()
async with async_session_maker() as session:
await initialize_all_tariff_weights(session)
await load_buttons_config(session)
await load_notifications_config(session)
await load_modes_config(session)
await load_payments_config(session)
await load_providers_order(session)
await load_money_config(session)
await load_management_config(session)
await load_tariffs_config(session)
await session.commit()
+1
View File
@@ -0,0 +1 @@
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
Binary file not shown.
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_BUTTONS_CONFIG
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True)
async def load_buttons_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
setting = Setting(
key="BUTTONS_CONFIG",
value=buttons_config,
description="Конфигурация кнопок бота",
)
session.add(setting)
else:
stored = setting.value or {}
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(stored)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
setting.value = buttons_config
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
await session.flush()
async def update_buttons_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="BUTTONS_CONFIG",
value=new_values,
description="Конфигурация кнопок бота",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(new_values)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
+60
View File
@@ -0,0 +1,60 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
async def load_management_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
setting = Setting(
key=MANAGEMENT_SETTING_KEY,
value=management_config,
description="Конфигурация управления ботом",
)
session.add(setting)
else:
stored = setting.value or {}
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(stored)
setting.value = management_config
MANAGEMENT_CONFIG.clear()
MANAGEMENT_CONFIG.update(management_config)
await session.flush()
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key=MANAGEMENT_SETTING_KEY,
value=new_values,
description="Конфигурация управления ботом",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(new_values)
MANAGEMENT_CONFIG.clear()
MANAGEMENT_CONFIG.update(management_config)
+57
View File
@@ -0,0 +1,57 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MODES_CONFIG
MODES_CONFIG: dict[str, bool] = DEFAULT_MODES_CONFIG.copy()
async def load_modes_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
modes_config = DEFAULT_MODES_CONFIG.copy()
setting = Setting(
key="MODES_CONFIG",
value=modes_config,
description="Конфигурация режимов работы бота",
)
session.add(setting)
else:
stored = setting.value or {}
modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(stored)
setting.value = modes_config
MODES_CONFIG.clear()
MODES_CONFIG.update(modes_config)
await session.flush()
async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="MODES_CONFIG",
value=new_values,
description="Конфигурация режимов работы бота",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(new_values)
MODES_CONFIG.clear()
MODES_CONFIG.update(modes_config)
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MONEY_CONFIG
MONEY_CONFIG: dict[str, Any] = DEFAULT_MONEY_CONFIG.copy()
def get_currency_mode() -> tuple[str, bool]:
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
raw = str(mode_cfg or "RUB").upper()
if raw not in ("RUB", "USD", "RUB+USD", "RUB+USD_ONE_SCREEN"):
raw = "RUB"
one_screen = raw == "RUB+USD_ONE_SCREEN"
if raw in ("RUB+USD", "RUB+USD_ONE_SCREEN"):
base_mode = "RUB+USD"
else:
base_mode = raw
return base_mode, one_screen
async def load_money_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
money_config = DEFAULT_MONEY_CONFIG.copy()
setting = Setting(
key="MONEY_CONFIG",
value=money_config,
description="Конфигурация валютных настроек",
)
session.add(setting)
else:
stored = setting.value or {}
money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(stored)
setting.value = money_config
MONEY_CONFIG.clear()
MONEY_CONFIG.update(money_config)
await session.flush()
async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="MONEY_CONFIG",
value=new_values,
description="Конфигурация валютных настроек",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(new_values)
MONEY_CONFIG.clear()
MONEY_CONFIG.update(money_config)
+59
View File
@@ -0,0 +1,59 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
NOTIFICATIONS_CONFIG: dict[str, Any] = DEFAULT_NOTIFICATIONS_CONFIG.copy()
async def load_notifications_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
setting = Setting(
key="NOTIFICATIONS_CONFIG",
value=notifications_config,
description="Конфигурация уведомлений",
)
session.add(setting)
else:
stored = setting.value or {}
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(stored)
setting.value = notifications_config
NOTIFICATIONS_CONFIG.clear()
NOTIFICATIONS_CONFIG.update(notifications_config)
await session.flush()
async def update_notifications_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="NOTIFICATIONS_CONFIG",
value=new_values,
description="Конфигурация уведомлений",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(new_values)
NOTIFICATIONS_CONFIG.clear()
NOTIFICATIONS_CONFIG.update(notifications_config)
+57
View File
@@ -0,0 +1,57 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_PAYMENTS_CONFIG
PAYMENTS_CONFIG: dict[str, bool] = DEFAULT_PAYMENTS_CONFIG.copy()
async def load_payments_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
setting = Setting(
key="PAYMENTS_CONFIG",
value=payments_config,
description="Конфигурация платёжных провайдеров",
)
session.add(setting)
else:
stored = setting.value or {}
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(stored)
setting.value = payments_config
PAYMENTS_CONFIG.clear()
PAYMENTS_CONFIG.update(payments_config)
await session.flush()
async def update_payments_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="PAYMENTS_CONFIG",
value=new_values,
description="Конфигурация платёжных провайдеров",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(new_values)
PAYMENTS_CONFIG.clear()
PAYMENTS_CONFIG.update(payments_config)
+38
View File
@@ -0,0 +1,38 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
PROVIDERS_ORDER: dict[str, int] = {}
async def load_providers_order(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
PROVIDERS_ORDER.clear()
if setting and isinstance(setting.value, dict):
PROVIDERS_ORDER.update({k: int(v) for k, v in setting.value.items()})
await session.flush()
async def update_providers_order(session: AsyncSession, new_order: dict[str, int]) -> None:
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="PROVIDERS_ORDER",
value=new_order,
description="Порядок отображения платёжных провайдеров",
)
session.add(setting)
else:
setting.value = new_order
await session.commit()
PROVIDERS_ORDER.clear()
PROVIDERS_ORDER.update(new_order)
+193
View File
@@ -0,0 +1,193 @@
from math import ceil
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
TARIFFS_CONFIG: dict[str, Any] = {
"ALLOW_DOWNGRADE": True,
"KEY_ADDONS_PACK_MODE": "all",
"KEY_ADDONS_PRICE_BASE_MODE": "current",
}
async def load_tariffs_config(session: AsyncSession) -> None:
"""Загружает конфиг тарифов из БД."""
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
tariffs_config = TARIFFS_CONFIG.copy()
setting = Setting(
key="TARIFFS_CONFIG",
value=tariffs_config,
description="Конфигурация тарифов",
)
session.add(setting)
else:
stored = setting.value or {}
tariffs_config = TARIFFS_CONFIG.copy()
tariffs_config.update(stored)
setting.value = tariffs_config
TARIFFS_CONFIG.clear()
TARIFFS_CONFIG.update(tariffs_config)
await session.flush()
async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
"""Обновляет конфиг тарифов."""
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="TARIFFS_CONFIG",
value=new_values,
description="Конфигурация тарифов",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
tariffs_config = TARIFFS_CONFIG.copy()
tariffs_config.update(new_values)
TARIFFS_CONFIG.clear()
TARIFFS_CONFIG.update(tariffs_config)
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
base_device_limit = int(tariff.get("device_limit") or 1)
extra_devices = max(0, device_limit - base_device_limit)
if extra_devices <= 0:
return 0
step_price = int(tariff.get("device_step_rub") or 0)
overrides = tariff.get("device_overrides") or {}
override_total = overrides.get(str(device_limit))
if override_total is not None:
return int(ceil(float(override_total)))
return int(ceil(extra_devices * step_price))
def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
if traffic_gb is None:
return 0
traffic_limit_bytes = tariff.get("traffic_limit")
if traffic_limit_bytes:
base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
else:
base_traffic_gb = 0
step_price = int(tariff.get("traffic_step_rub") or 0)
overrides = tariff.get("traffic_overrides") or {}
override_total = overrides.get(str(traffic_gb))
if override_total is not None:
return int(ceil(float(override_total)))
if traffic_gb == 0:
return 0
extra_gb = max(0, traffic_gb - base_traffic_gb)
if extra_gb <= 0:
return 0
return int(ceil(extra_gb * step_price))
def calculate_config_price(
tariff: dict[str, Any],
duration_days: int,
device_limit: int,
traffic_gb: int | None,
) -> int:
base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
if base_duration <= 0:
base_duration = duration_days or 30
base_price = int(tariff.get("price_rub") or 0)
duration_multiplier = duration_days / base_duration
base_price_scaled = base_price * duration_multiplier
extra_devices_price = calc_extra_devices_price(tariff, device_limit)
extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
total = base_price_scaled + extra_devices_price + extra_traffic_price
return int(ceil(total))
def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
raw_duration_options = tariff.get("duration_options") or []
duration_options: list[int] = []
for value in raw_duration_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v > 0:
duration_options.append(v)
if not duration_options:
base_duration = int(tariff.get("duration_days") or 0) or 30
duration_options = [base_duration]
duration_options = sorted(set(duration_options))
raw_device_options = tariff.get("device_options") or []
device_options: list[int] = []
for value in raw_device_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v > 0:
device_options.append(v)
if not device_options:
base_device_limit = int(tariff.get("device_limit") or 0)
if base_device_limit > 0:
device_options = [base_device_limit]
else:
device_options = []
device_options = sorted(set(device_options))
raw_traffic_options = tariff.get("traffic_options_gb")
traffic_options_gb: list[int] | None
if raw_traffic_options is None:
traffic_options_gb = None
else:
traffic_values: list[int] = []
has_unlimited = False
for value in raw_traffic_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v == 0:
has_unlimited = True
elif v > 0:
traffic_values.append(v)
if not traffic_values and not has_unlimited:
traffic_options_gb = None
else:
unique_values = sorted(set(traffic_values))
if has_unlimited:
traffic_options_gb = [0] + unique_values
else:
traffic_options_gb = unique_values
return {
"duration_options": duration_options,
"device_options": device_options,
"traffic_options_gb": traffic_options_gb,
}
+53 -2
View File
@@ -8,21 +8,50 @@ from database.models import Coupon, CouponUsage
from logger import logger
async def create_coupon(session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None) -> bool:
async def create_coupon(
session: AsyncSession,
code: str,
amount: int | None,
usage_limit: int,
days: int | None = None,
new_users_only: bool = False,
percent: int | None = None,
max_discount_amount: int | None = None,
min_order_amount: int | None = None,
) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
return False
if percent is not None:
try:
percent_value = int(percent)
except (TypeError, ValueError):
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
return False
if percent_value <= 0 or percent_value > 100:
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
return False
if (amount or 0) > 0 or (days or 0) > 0:
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
return False
await session.execute(
insert(Coupon).values(
code=code,
amount=amount,
amount=int(amount) if amount is not None else 0,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
new_users_only=new_users_only,
percent=percent,
max_discount_amount=max_discount_amount,
min_order_amount=min_order_amount,
)
)
await session.commit()
@@ -84,6 +113,7 @@ async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: in
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
await session.rollback()
raise
async def check_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int) -> bool:
@@ -107,3 +137,24 @@ async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
await session.rollback()
raise
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
percent = coupon.percent
if percent is None:
return price_rub, 0
if coupon.min_order_amount is not None and price_rub < int(coupon.min_order_amount):
return price_rub, 0
discount = (price_rub * int(percent)) // 100
if coupon.max_discount_amount is not None:
discount = min(discount, int(coupon.max_discount_amount))
final_price = price_rub - discount
if final_price < 0:
final_price = 0
return final_price, discount
+42 -3
View File
@@ -1,11 +1,50 @@
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from config import DATABASE_URL
from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE
engine = create_async_engine(DATABASE_URL, echo=False, future=True, pool_size=20, max_overflow=30, pool_timeout=15)
CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW
MAX_UPDATE_AGE_SEC = 28
CONCURRENT_UPDATES_WAIT_TIMEOUT_SEC = 8
CONCURRENT_UPDATES_GATE_LIMIT = 150
CONCURRENT_UPDATES_GATE_WAIT_SEC = 2
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
engine = create_async_engine(
DATABASE_URL,
echo=False,
future=True,
pool_size=DB_POOL_SIZE,
max_overflow=DB_MAX_OVERFLOW,
pool_timeout=60,
pool_pre_ping=True,
pool_recycle=300,
)
async_session_maker = async_sessionmaker(
bind=engine,
expire_on_commit=False,
class_=AsyncSession,
)
Base = declarative_base()
WARM_POOL_COUNT = 10
async def warm_pool() -> None:
"""
Прогревает пул соединений при старте.
"""
async def _one() -> None:
async with async_session_maker() as session:
await session.execute(text("SELECT 1"))
count = min(WARM_POOL_COUNT, DB_POOL_SIZE)
if count <= 0:
return
await asyncio.gather(*[asyncio.create_task(_one()) for _ in range(count)])
+12 -2
View File
@@ -18,6 +18,9 @@ async def store_gift_link(
tariff_id: int | None = None,
is_unlimited: bool = False,
max_usages: int | None = None,
selected_device_limit: int | None = None,
selected_traffic_gb: int | None = None,
selected_price_rub: int | None = None,
):
try:
stmt = insert(Gift).values(
@@ -32,12 +35,19 @@ async def store_gift_link(
tariff_id=tariff_id,
is_unlimited=is_unlimited,
max_usages=max_usages,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id}, max_usages={max_usages})")
logger.info(
f"🎁 Подарок {gift_id} сохранён "
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
return False
raise
+2 -1
View File
@@ -1,6 +1,7 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, User
@@ -16,7 +17,7 @@ async def get_hot_leads(session: AsyncSession):
.where(User.trial == 1)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(~Payment.tg_id.in_(sub_active))
)
+10 -5
View File
@@ -10,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import USE_COUNTRY_SELECTION
from core.bootstrap import MODES_CONFIG
from database.models import Key, Server, User
@@ -17,7 +18,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
imported = 0
skipped = 0
if USE_COUNTRY_SELECTION:
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
result = await session.execute(
select(Server.server_name).where(Server.enabled.is_(True), Server.panel_type == "3x-ui")
)
@@ -89,8 +92,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
updated_at=datetime.utcnow(),
)
)
except SQLAlchemyError:
continue
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте пользователя tg_id={tg_id}") from e
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
if key_exists.scalar():
@@ -116,8 +120,9 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
)
)
imported += 1
except SQLAlchemyError:
continue
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте ключа client_id={client_id}") from e
await session.commit()
return imported, skipped
-3
View File
@@ -5,7 +5,6 @@ from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
from database.tariffs import initialize_all_tariff_weights
async def init_db():
@@ -35,5 +34,3 @@ async def init_db():
)
)
await session.commit()
await initialize_all_tariff_weights(session)
+110 -22
View File
@@ -17,29 +17,49 @@ async def store_key(
key: str,
server_id: str,
remnawave_link: str = None,
tariff_id: int = None,
alias: str = None,
tariff_id: int | None = None,
alias: str | None = None,
selected_device_limit: int | None = None,
selected_traffic_limit: int | None = None,
selected_price_rub: int | None = None,
current_device_limit: int | None = None,
current_traffic_limit: int | None = None,
):
"""Сохраняет или обновляет ключ подписки."""
try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none()
if existing_key:
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.client_id == client_id)
.values(
email=email,
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
)
)
values: dict = {
"email": email,
"expiry_time": expiry_time,
"key": key,
"server_id": server_id,
"remnawave_link": remnawave_link,
"tariff_id": tariff_id,
"alias": alias,
}
if selected_device_limit is not None:
values["selected_device_limit"] = selected_device_limit
if selected_traffic_limit is not None:
values["selected_traffic_limit"] = selected_traffic_limit
if selected_price_rub is not None:
values["selected_price_rub"] = selected_price_rub
if current_device_limit is not None:
values["current_device_limit"] = current_device_limit
if current_traffic_limit is not None:
values["current_traffic_limit"] = current_traffic_limit
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
if current_device_limit is None:
current_device_limit = selected_device_limit
if current_traffic_limit is None:
current_traffic_limit = selected_traffic_limit
new_key = Key(
tg_id=tg_id,
client_id=client_id,
@@ -51,15 +71,21 @@ async def store_key(
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
current_device_limit=current_device_limit,
current_traffic_limit=current_traffic_limit,
)
session.add(new_key)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
await session.commit()
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
raise
async def get_keys(session: AsyncSession, tg_id: int):
@@ -79,6 +105,7 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
"""Возвращает подробную информацию о ключе по email."""
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
result = await session.execute(stmt)
row = result.first()
@@ -116,6 +143,11 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
"cluster_name": key.server_id,
"location_name": key.server_id,
"tariff_id": key.tariff_id,
"selected_device_limit": key.selected_device_limit,
"selected_traffic_limit": key.selected_traffic_limit,
"selected_price_rub": key.selected_price_rub,
"current_device_limit": key.current_device_limit,
"current_traffic_limit": key.current_traffic_limit,
}
@@ -124,10 +156,11 @@ async def get_key_count(session: AsyncSession, tg_id: int) -> int:
return result.scalar() or 0
async def delete_key(session: AsyncSession, identifier: int | str):
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
await session.execute(stmt)
await session.commit()
if commit:
await session.commit()
logger.info(f"Ключ с идентификатором {identifier} удалён")
@@ -156,13 +189,18 @@ async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str,
is_frozen = TRUE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
"""
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
)
async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int):
async def mark_key_as_unfrozen(
session: AsyncSession,
tg_id: int,
client_id: str,
new_expiry_time: int,
):
await session.execute(
text(
"""
@@ -171,7 +209,7 @@ async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str
is_frozen = FALSE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
"""
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
)
@@ -199,3 +237,53 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
res = await session.execute(q)
await session.commit()
return res.scalar_one_or_none() is not None
async def save_key_config_with_mode(
session: AsyncSession,
email: str,
selected_devices: int | None,
selected_traffic_gb: int | None,
total_price: int,
has_device_choice: bool,
has_traffic_choice: bool,
config_mode: str,
) -> None:
values: dict = {}
if config_mode == "pack":
if has_device_choice and selected_devices is not None:
values["current_device_limit"] = int(selected_devices)
if has_traffic_choice and selected_traffic_gb is not None:
values["current_traffic_limit"] = int(selected_traffic_gb)
else:
device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
values["selected_device_limit"] = device_val
values["selected_traffic_limit"] = traffic_val
values["selected_price_rub"] = int(total_price)
values["current_device_limit"] = device_val
values["current_traffic_limit"] = traffic_val
if not values:
return
await session.execute(update(Key).where(Key.email == email).values(**values))
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
"""Сбрасывает текущие лимиты к выбранным для ключа."""
await session.execute(
text(
"""
UPDATE keys
SET current_device_limit = selected_device_limit,
current_traffic_limit = selected_traffic_limit
WHERE client_id = :client_id
"""
),
{"client_id": client_id},
)
await session.commit()
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
+39
View File
@@ -16,6 +16,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
@@ -77,6 +78,13 @@ class Key(DictLikeMixin, Base):
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_limit = Column(BigInteger, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
current_device_limit = Column(Integer, nullable=True)
current_traffic_limit = Column(BigInteger, nullable=True)
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
@@ -94,6 +102,18 @@ class Tariff(DictLikeMixin, Base):
subgroup_title = Column(String, nullable=True)
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True)
configurable = Column(Boolean, nullable=False, server_default="false")
device_options = Column(JSONB, nullable=True)
traffic_options_gb = Column(JSONB, nullable=True)
device_step_rub = Column(Integer, nullable=True)
device_overrides = Column(JSONB, nullable=True)
traffic_step_rub = Column(Integer, nullable=True)
traffic_overrides = Column(JSONB, nullable=True)
class Server(DictLikeMixin, Base):
@@ -164,6 +184,11 @@ class Coupon(DictLikeMixin, Base):
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
new_users_only = Column(Boolean, nullable=False, server_default=text("false"))
percent = Column(Integer, nullable=True)
max_discount_amount = Column(Integer, nullable=True)
min_order_amount = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
@@ -205,6 +230,10 @@ class Gift(DictLikeMixin, Base):
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_gb = Column(Integer, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
class GiftUsage(DictLikeMixin, Base):
__tablename__ = "gift_usages"
@@ -262,3 +291,13 @@ class Admin(Base):
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
class Setting(DictLikeMixin, Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(JSONB, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+14 -3
View File
@@ -6,6 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from core.bootstrap import NOTIFICATIONS_CONFIG
from database.models import Key, Notification, User
from logger import logger
@@ -30,6 +31,7 @@ async def add_notification(session: AsyncSession, tg_id: int, notification_type:
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
await session.rollback()
raise
async def delete_notification(session: AsyncSession, tg_id: int, notification_type: str):
@@ -81,7 +83,9 @@ async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
notification_type, last_time = row
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
expires_at = last_time + timedelta(hours=hours)
current_time = datetime.utcnow()
if current_time > expires_at:
@@ -89,10 +93,16 @@ async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
return {
"available": True,
"type": notification_type,
"tariff_group": tariff_group,
"expires_at": expires_at,
}
except Exception as e:
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
await session.rollback()
return {"available": False}
@@ -157,7 +167,7 @@ async def check_notifications_bulk(
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": (int(last_time.timestamp() * 1000) if last_time else None),
"last_notification_time": int(last_time.timestamp() * 1000) if last_time else None,
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
@@ -165,4 +175,5 @@ async def check_notifications_bulk(
except Exception as e:
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
await session.rollback()
return []
+63 -14
View File
@@ -1,5 +1,4 @@
from datetime import datetime, timedelta
from typing import Optional
from pytz import timezone
from sqlalchemy import and_, insert, select, update
@@ -44,7 +43,6 @@ async def add_payment(
)
result = await session.execute(stmt)
internal_id = result.scalar_one()
await session.commit()
logger.info(
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
)
@@ -91,13 +89,23 @@ async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | N
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж id={internal_id}")
return dict(payment.__dict__)
logger.info(f"Платёж id={internal_id} не найден")
return None
if not payment:
return None
return {
"id": payment.id,
"tg_id": payment.tg_id,
"amount": payment.amount,
"currency": payment.currency,
"status": payment.status,
"payment_system": payment.payment_system,
"payment_id": payment.payment_id,
"created_at": payment.created_at,
"metadata": payment.metadata_,
"original_amount": payment.original_amount,
}
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
await session.rollback()
return None
@@ -137,13 +145,23 @@ async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | N
try:
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж payment_id={pid}")
return dict(payment.__dict__)
logger.info(f"Платёж payment_id={pid} не найден")
return None
if not payment:
return None
return {
"id": payment.id,
"tg_id": payment.tg_id,
"amount": payment.amount,
"currency": payment.currency,
"status": payment.status,
"payment_system": payment.payment_system,
"payment_id": payment.payment_id,
"created_at": payment.created_at,
"metadata": payment.metadata_,
"original_amount": payment.original_amount,
}
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
await session.rollback()
return None
@@ -158,8 +176,39 @@ async def cancel_expired_pending_payments(session: AsyncSession) -> int:
)
)
.values(status="cancelled")
.returning(Payment.id)
)
res = await session.execute(stmt)
await session.commit()
return len(res.fetchall())
affected = res.rowcount or 0
return affected
async def get_all_payments(
session: AsyncSession,
tg_id: int,
statuses: list[str] | None = None,
) -> list[dict]:
query = select(Payment).where(Payment.tg_id == tg_id)
if statuses:
query = query.where(Payment.status.in_(statuses))
query = query.order_by(Payment.created_at.desc())
result = await session.execute(query)
payments = result.scalars().all()
return [
{
"id": p.id,
"tg_id": p.tg_id,
"amount": p.amount,
"currency": p.currency,
"status": p.status,
"payment_system": p.payment_system,
"payment_id": p.payment_id,
"created_at": p.created_at,
"metadata": p.metadata_,
"original_amount": p.original_amount,
}
for p in payments
]
+20 -4
View File
@@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
from core.bootstrap import BUTTONS_CONFIG
from database.models import Referral
from logger import logger
@@ -57,6 +58,11 @@ async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
if not referral_enabled:
logger.debug("Реферальная программа отключена, бонусы не начисляются")
return 0.0
if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """
WITH RECURSIVE
@@ -81,7 +87,8 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m
earliest_payments AS (
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
FROM payments
WHERE status = 'success'
WHERE status = 'success'
AND payment_system NOT IN ('coupon', 'admin', 'referral')
ORDER BY tg_id, created_at
)
"""
@@ -148,11 +155,16 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m
), 0) AS total_bonus
FROM referral_levels rl
JOIN payments p ON rl.referred_tg_id = p.tg_id
WHERE p.status = 'success' AND rl.level <= :max_levels
WHERE p.status = 'success'
AND p.payment_system NOT IN ('coupon', 'admin', 'referral')
AND rl.level <= :max_levels
"""
)
result = await session.execute(text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels})
result = await session.execute(
text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
{"tg_id": referrer_tg_id, "max_levels": max_levels},
)
total_bonus_raw = result.scalar()
total_bonus = round(float(total_bonus_raw or 0), 2)
@@ -180,7 +192,10 @@ async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max
GROUP BY level
ORDER BY level
"""
result = await session.execute(text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels})
result = await session.execute(
text(query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
{"referrer_tg_id": referrer_tg_id, "max_levels": max_levels},
)
return {
row["level"]: {
"total": row["level_count"],
@@ -209,6 +224,7 @@ async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
except Exception as e:
logger.error(f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}")
await session.rollback()
raise
+99 -8
View File
@@ -53,12 +53,16 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
ids = [s.id for s in servers]
subs_map = {}
tariffs_map = {}
if ids:
r = await session.execute(
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
)
for sid, sg in r.all():
subs_map.setdefault(sid, []).append(sg)
if sg and sg.isdigit():
tariffs_map.setdefault(sid, []).append(int(sg))
else:
subs_map.setdefault(sid, []).append(sg)
groups_map = {}
if ids:
@@ -88,12 +92,15 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"tariff_subgroups": subs_map.get(s.id, []),
"tariff_ids": tariffs_map.get(s.id, []),
"special_groups": special,
"cluster_name": cluster,
"server_id": s.id,
})
return grouped
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении серверов: {e}")
await session.rollback()
return {}
@@ -118,6 +125,7 @@ async def check_server_name_by_cluster(session: AsyncSession, server_name: str)
return {"cluster_name": row[0]} if row else None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
await session.rollback()
return None
@@ -155,6 +163,7 @@ async def get_server_by_name(session: AsyncSession, server_name: str) -> dict |
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
await session.rollback()
return None
@@ -203,6 +212,7 @@ async def get_available_clusters(session: AsyncSession) -> list[str]:
return [row[0] for row in result.all()]
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении списка кластеров: {e}")
await session.rollback()
return []
@@ -272,12 +282,32 @@ async def resolve_device_limit_from_group(session: AsyncSession, server_id: str)
async def filter_cluster_by_subgroup(
session: AsyncSession, cluster: list, target_subgroup: str, cluster_id: str
session: AsyncSession,
cluster: list,
target_subgroup: str,
cluster_id: str,
tariff_id: int | None = None,
) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
if tariff_id:
tariff_id_str = str(tariff_id)
q_by_tariff = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == tariff_id_str,
)
)
allowed_by_tariff = {n for (n,) in q_by_tariff.all()}
if allowed_by_tariff:
logger.debug(f"Найдены серверы по tariff_id={tariff_id}: {allowed_by_tariff}")
return [s for s in cluster if s.get("server_name") in allowed_by_tariff]
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
@@ -291,11 +321,15 @@ async def filter_cluster_by_subgroup(
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
total_for_subgroup = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == target_subgroup)
check_values = [target_subgroup]
if tariff_id:
check_values.append(str(tariff_id))
total_bindings = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title.in_(check_values))
)
if not total_for_subgroup:
logger.info(f"Для подгруппы {target_subgroup} нет ни одного сервера. Используем весь кластер {cluster_id}.")
if not total_bindings:
logger.info(f"Для подгруппы/тарифа нет привязок. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
@@ -308,8 +342,65 @@ async def filter_cluster_by_subgroup(
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}. Продление пропущено.")
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}.")
return []
logger.info(f"В кластере {cluster_id} нет привязок подгрупп. Продлеваем по всему кластеру.")
logger.info(f"В кластере {cluster_id} нет привязок. Используем весь кластер.")
return cluster
async def filter_cluster_by_tariff(session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
tariff_id_str = str(tariff_id)
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == tariff_id_str,
)
)
allowed = {n for (n,) in q_allowed.all()}
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
total_for_tariff = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == tariff_id_str)
)
if not total_for_tariff:
logger.info(f"Для тарифа {tariff_id} нет привязок серверов. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
)
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под тариф {tariff_id} в кластере {cluster_id}.")
return []
logger.info(f"В кластере {cluster_id} нет привязок тарифов. Используем весь кластер.")
return cluster
async def has_legacy_subgroup_bindings(session: AsyncSession, server_ids: list[int]) -> bool:
if not server_ids:
return False
result = await session.execute(
select(ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(server_ids))
)
for (title,) in result.all():
if title and not title.isdigit():
return True
return False
+37
View File
@@ -0,0 +1,37 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Setting
async def get_setting(session: AsyncSession, key: str, default: Any = None) -> Any:
stmt = select(Setting).where(Setting.key == key)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None or setting.value is None:
return default
return setting.value
async def set_setting(
session: AsyncSession,
key: str,
value: Any,
description: str | None = None,
) -> Setting:
stmt = select(Setting).where(Setting.key == key)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(key=key, value=value, description=description)
session.add(setting)
else:
setting.value = value
if description is not None:
setting.description = description
await session.flush()
return setting
+33 -6
View File
@@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import and_, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, Referral, Tariff, User
@@ -33,10 +34,28 @@ async def count_active_keys(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms))
async def count_trial_keys(session: AsyncSession) -> int:
async def count_active_paid_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
return await session.scalar(
select(func.count())
.select_from(Key)
.where(Key.expiry_time > current_time_ms)
.where(~Key.tariff_id.in_(trial_tariffs_subquery))
)
async def count_active_trial_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(
select(func.count())
.select_from(Key)
.where(Key.expiry_time > current_time_ms)
.where(Key.tariff_id.in_(trial_tariffs_subquery))
)
async def get_tariff_distribution(
@@ -80,6 +99,14 @@ async def get_tariff_durations(session: AsyncSession, tariff_ids: list[int]) ->
return dict(result.all())
async def get_tariff_subgroups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str | None]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.subgroup_title).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def count_total_referrals(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Referral))
@@ -90,7 +117,7 @@ async def sum_payments_since(session: AsyncSession, since: date) -> float:
and_(
Payment.created_at >= since,
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
@@ -104,7 +131,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
Payment.created_at >= start,
Payment.created_at < end,
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
@@ -116,7 +143,7 @@ async def sum_total_payments(session: AsyncSession) -> float:
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
@@ -132,7 +159,7 @@ async def count_hot_leads(session: AsyncSession) -> int:
select(Payment.tg_id)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
.distinct()
)
+4
View File
@@ -73,6 +73,7 @@ async def get_tariffs(
return tariffs
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
await session.rollback()
return []
@@ -83,6 +84,7 @@ async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
return dict(tariff.__dict__) if tariff else None
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
await session.rollback()
return None
@@ -179,6 +181,7 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int):
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
await session.rollback()
return False
@@ -195,6 +198,7 @@ async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
return sort_order
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
await session.rollback()
return None
+1
View File
@@ -25,6 +25,7 @@ async def create_temporary_data(session: AsyncSession, tg_id: int, state: str, d
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
await session.rollback()
raise
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
+10 -7
View File
@@ -2,13 +2,11 @@ from sqlalchemy import and_, func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Payment, TrackingSource, User
from logger import logger
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
try:
stmt = insert(TrackingSource).values(
@@ -23,6 +21,7 @@ async def create_tracking_source(session: AsyncSession, name: str, code: str, ty
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
await session.rollback()
raise
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
@@ -43,7 +42,11 @@ async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where((User.source_code == TrackingSource.code) & (Payment.status == "success"))
.where(
(User.source_code == TrackingSource.code)
& (Payment.status == "success")
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
)
.correlate(TrackingSource)
.scalar_subquery()
)
@@ -103,7 +106,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.scalar_subquery()
@@ -115,7 +118,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.scalar_subquery()
@@ -146,7 +149,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.subquery()
+91 -61
View File
@@ -30,7 +30,8 @@ async def add_user(
language_code: str = None,
is_bot: bool = False,
source_code: str = None,
):
commit: bool = True,
) -> bool:
try:
stmt = (
insert(User)
@@ -43,11 +44,17 @@ async def add_user(
is_bot=is_bot,
source_code=source_code,
)
.on_conflict_do_nothing(index_elements=[User.tg_id])
.on_conflict_do_nothing(index_elements=["tg_id"])
.returning(User.tg_id)
)
await session.execute(stmt)
await session.commit()
res = await session.execute(stmt)
inserted_tg_id = res.scalar_one_or_none()
if inserted_tg_id is None:
return False
if commit:
await session.commit()
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
return True
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
await session.rollback()
@@ -56,6 +63,12 @@ async def add_user(
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
try:
if amount < 0:
current = await get_balance(session, tg_id)
if current + amount < 0:
logger.warning(f"[DB] Недостаточно средств: tg_id={tg_id} balance={current} списание={amount}")
await session.rollback()
raise ValueError(f"Недостаточно средств: баланс {current}, списание {amount}")
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
@@ -82,7 +95,8 @@ async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
async def get_balance(session: AsyncSession, tg_id: int) -> float:
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
return round(float(result.scalar_one()), 1)
balance = result.scalar_one_or_none()
return round(float(balance or 0.0), 1)
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
@@ -92,6 +106,7 @@ async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) ->
except SQLAlchemyError as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
await session.rollback()
raise
async def update_trial(session: AsyncSession, tg_id: int, status: int):
@@ -102,11 +117,13 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int):
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
await session.rollback()
raise
async def get_trial(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
return int(result.scalar_one())
trial = result.scalar_one_or_none()
return int(trial or 0)
async def upsert_user(
@@ -119,58 +136,64 @@ async def upsert_user(
is_bot: bool = False,
only_if_exists: bool = False,
) -> dict | None:
"""Создаёт пользователя или обновляет поля профиля."""
try:
now = datetime.utcnow()
returning_cols = list(User.__table__.c)
if only_if_exists:
result = await session.execute(select(User).where(User.tg_id == tg_id))
user = result.scalar_one_or_none()
if not user:
return None
await session.execute(
username_value = username if username else User.username
first_name_value = first_name if first_name else User.first_name
last_name_value = last_name if last_name else User.last_name
language_code_value = language_code if language_code else User.language_code
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(
username=username or user.username,
first_name=first_name or user.first_name,
last_name=last_name or user.last_name,
language_code=language_code or user.language_code,
username=username_value,
first_name=first_name_value,
last_name=last_name_value,
language_code=language_code_value,
is_bot=is_bot,
updated_at=datetime.utcnow(),
updated_at=now,
)
.returning(*returning_cols)
)
row = res.mappings().one_or_none()
if row is None:
return None
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
else:
res = await session.execute(
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
"is_bot": is_bot,
"updated_at": datetime.utcnow(),
},
)
.returning(User)
return dict(row)
res = await session.execute(
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
created_at=now,
updated_at=now,
)
obj = res.scalar_one()
await session.commit()
d = obj.__dict__.copy()
d.pop("_sa_instance_state", None)
return d
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
"is_bot": is_bot,
"updated_at": now,
},
)
.returning(*returning_cols)
)
row = res.mappings().one()
await session.commit()
return dict(row)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
await session.rollback()
@@ -190,7 +213,7 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
delete(Referral).where(or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id))
)
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
await delete_key(session, tg_id)
await delete_key(session, tg_id, commit=False)
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
await session.execute(delete(User).where(User.tg_id == tg_id))
@@ -208,30 +231,37 @@ async def mark_trial_extended(tg_id: int, session: AsyncSession):
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
res = await session.execute(
select(func.coalesce(User.trial, 0), func.count(Key.client_id))
.select_from(User)
.join(Key, Key.tg_id == User.tg_id, isouter=True)
.where(User.tg_id == tg_id)
.group_by(User.tg_id, User.trial)
)
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
row = res.first()
if row is None:
return None
return int(row[0]), int(row[1])
async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None:
async def upsert_source_if_empty(
session: AsyncSession,
tg_id: int,
source_code: str,
commit: bool = True,
) -> bool:
if not source_code:
return
return False
stmt = (
insert(User)
.values(tg_id=tg_id, source_code=source_code)
.on_conflict_do_update(
index_elements=[User.tg_id],
index_elements=["tg_id"],
set_={"source_code": insert(User).excluded.source_code},
where=(User.source_code.is_(None)),
)
.returning(User.tg_id)
)
await session.execute(stmt)
await session.commit()
res = await session.execute(stmt)
changed_tg_id = res.scalar_one_or_none()
if changed_tg_id is None:
return False
if commit:
await session.commit()
return True
+9
View File
@@ -0,0 +1,9 @@
services:
bot:
container_name: solobot
build: .
restart: unless-stopped
network_mode: host
volumes:
- /:/host:ro
+57 -4
View File
@@ -1,17 +1,70 @@
import time
from aiogram.filters import BaseFilter
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker
from database.models import Admin
_ADMIN_CACHE: dict[int, tuple[float, bool, bool]] = {}
_ADMIN_CACHE_TTL = 60
def _get_cached_admin(user_id: int) -> tuple[bool, bool] | None:
now = time.time()
entry = _ADMIN_CACHE.get(user_id)
if entry and entry[0] > now:
return entry[1], entry[2]
return None
def _set_cached_admin(user_id: int, is_admin: bool, is_superadmin: bool) -> None:
_ADMIN_CACHE[user_id] = (time.time() + _ADMIN_CACHE_TTL, is_admin, is_superadmin)
class IsAdminFilter(BaseFilter):
async def __call__(self, event: Message | CallbackQuery) -> bool:
if not event.from_user:
return False
user_id = event.from_user.id
cached = _get_cached_admin(user_id)
if cached is not None:
return cached[0]
try:
async with async_session_maker() as session:
result = await session.execute(select(Admin).where(Admin.tg_id == event.from_user.id))
admin = result.scalar_one_or_none()
return admin is not None
except Exception:
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
admin_ids = (ADMIN_ID,) if isinstance(ADMIN_ID, int) else ADMIN_ID
is_admin = admin is not None or user_id in admin_ids
is_super = admin.role != "moderator" if admin else (user_id in admin_ids)
_set_cached_admin(user_id, is_admin, is_super)
return is_admin
except (Exception,):
return False
class IsSuperAdminFilter(BaseFilter):
async def __call__(self, event: Message | CallbackQuery) -> bool:
if not event.from_user:
return False
user_id = event.from_user.id
cached = _get_cached_admin(user_id)
if cached is not None:
return cached[1]
try:
async with async_session_maker() as session:
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
if not admin:
_set_cached_admin(user_id, False, False)
return False
is_super = admin.role != "moderator"
_set_cached_admin(user_id, True, is_super)
return is_super
except (Exception,):
return False
+2
View File
@@ -13,6 +13,7 @@ from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
from .tariffs import router as tariff_router
router = Router(name="handlers_main_router")
@@ -29,4 +30,5 @@ router.include_routers(
instructions_router,
admin_router,
refferal_router,
tariff_router,
)
+4
View File
@@ -7,6 +7,7 @@ from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .emoji import router as emoji_router
from .gifts import router as gifts_router
from .management import router as management_router
from .module import router as modules_router
@@ -14,6 +15,7 @@ from .panel import router as panel_router
from .restart import router as restart_router
from .sender import router as sender_router
from .servers import router as servers_router
from .settings import router as settings_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
@@ -32,9 +34,11 @@ router.include_routers(
backups_router,
sender_router,
coupons_router,
emoji_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
gifts_router,
settings_router,
)
+2 -1
View File
@@ -3,6 +3,7 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
from handlers.buttons import BACK
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
@@ -43,7 +44,7 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
text=BACK,
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
+204 -39
View File
@@ -7,17 +7,23 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from sqlalchemy import delete, text
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import ManualBan
from database.models import BlockedUser, Key, ManualBan
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
build_bans_kb,
build_blocked_users_kb,
build_manual_bans_kb,
build_shadow_bans_kb,
)
router = Router()
@@ -32,25 +38,59 @@ async def handle_bans(callback_query: CallbackQuery):
text_ = (
"🚫 <b>Управление банами</b>\n\n"
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
"👻 <b>Теневые баны</b> — пользователи, действия которых игнорируются.\n"
"🔒 <b>Ручные баны</b> — пользователи, которых вы забанили через админку.\n\n"
"⬇ Выберите нужный раздел:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_blocked_menu"), IsAdminFilter())
async def handle_blocked_users_menu(callback_query: CallbackQuery):
text_ = (
"📛 <b>Забанившие бота</b>\n\n"
"Пользователи, которые заблокировали бота вручную или удалили чат.\n"
"⬇ Выберите действие:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_blocked_users_kb())
def get_shadow_bans_menu_text() -> str:
return (
"👻 <b>Теневые баны</b>\n\n"
"Пользователи, действия которых игнорируются ботом.\n"
"Они не получают уведомлений о бане.\n\n"
"💡 <b>Можно добавить несколько пользователей за раз:</b>\n"
"Отправьте список Telegram ID (один на строке).\n"
"Пример:\n<code>123456789\n987654321\n555666777</code>\n\n"
"⬇ Выберите действие:"
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
async def handle_manual_bans_menu(callback_query: CallbackQuery):
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter())
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("management")
kb = build_blocked_users_kb()
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
banned_users = result.all()
result = await session.execute(select(BlockedUser.tg_id))
banned_users = result.scalars().all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id"])
for user in banned_users:
writer.writerow([user.tg_id])
for tg_id in banned_users:
writer.writerow([tg_id])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
@@ -68,11 +108,11 @@ async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSessio
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter())
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("bans")
kb = build_blocked_users_kb()
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
blocked_users = result.all()
blocked_ids = [user.tg_id for user in blocked_users]
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
result = await session.execute(stmt)
blocked_ids = [row[0] for row in result.all()]
if not blocked_ids:
await callback_query.message.answer(
@@ -84,12 +124,6 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Asyn
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute(
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
{"blocked_ids": blocked_ids},
)
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
reply_markup=kb,
@@ -101,60 +135,188 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Asyn
)
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
build_admin_back_kb("bans")
@router.callback_query(AdminPanelCallback.filter(F.action == "shadow_bans_export"), IsAdminFilter())
async def handle_shadow_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_shadow_bans_kb()
try:
result = await session.execute(text("SELECT tg_id, banned_at, reason, until FROM manual_bans"))
result = await session.execute(
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
ManualBan.reason == "shadow"
)
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until"])
writer.writerow(["tg_id", "banned_at", "banned_by", "until"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
writer.writerow([user.tg_id, user.banned_at, user.banned_by, user.until])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="shadow_bans.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт теневых банов (CSV)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_manual_bans_kb()
try:
result = await session.execute(
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
)
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until", "banned_by"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until, user.banned_by])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт вручную забаненных пользователей",
caption="📥 Экспорт вручную забаненных пользователей (CSV)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=build_admin_back_kb("bans"),
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter())
async def handle_delete_manual_banned(callback_query: CallbackQuery, session: AsyncSession):
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_blocked"), IsAdminFilter())
async def handle_clear_blocked_users(callback_query: CallbackQuery, session: AsyncSession):
kb = build_blocked_users_kb()
try:
await session.execute(delete(ManualBan))
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет забанивших пользователей для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(BlockedUser))
await session.commit()
await callback_query.message.edit_text(
"🗑️ Вручную забаненные пользователи удалены.",
reply_markup=build_bans_kb(),
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
reply_markup=kb,
)
logger.info("[BANS] Очищены записи из manual_bans")
logger.info(f"[BANS] Очищено {total_count} записей из blocked_users")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.")
logger.error(f"[BANS] Ошибка при очистке blocked_users: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке забанивших пользователей: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_shadow"), IsAdminFilter())
async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: AsyncSession):
kb = build_shadow_bans_kb()
try:
count_result = await session.execute(
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
)
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет теневых банов для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
reply_markup=kb,
)
logger.info(f"[BANS] Очищено {total_count} записей теневых банов из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке теневых банов: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке теневых банов: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_manual"), IsAdminFilter())
async def handle_clear_manual_bans(callback_query: CallbackQuery, session: AsyncSession):
kb = build_manual_bans_kb()
try:
count_result = await session.execute(
select(func.count())
.select_from(ManualBan)
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
)
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет ручных банов для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
reply_markup=kb,
)
logger.info(f"[BANS] Очищено {total_count} записей ручных банов из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке ручных банов: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке ручных банов: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Отмена",
callback_data=AdminPanelCallback(action="bans_cancel_preemptive").pack(),
)
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>",
reply_markup=build_admin_back_kb("bans"),
reply_markup=builder.as_markup(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession):
lines = message.text.strip().splitlines()
@@ -197,5 +359,8 @@ async def handle_preemptive_ids_input(message: Message, state: FSMContext, sessi
await session.execute(stmt)
await session.commit()
await message.answer(f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
await message.answer(
f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.",
reply_markup=build_shadow_bans_kb(),
)
await state.clear()
+73 -12
View File
@@ -1,6 +1,7 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
def build_bans_kb():
@@ -8,27 +9,87 @@ def build_bans_kb():
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_blocked_menu").pack(),
)
builder.button(
text="👻 Теневые баны",
callback_data=AdminPanelCallback(action="bans_shadow_menu").pack(),
)
builder.button(
text="🔒 Ручные баны",
callback_data=AdminPanelCallback(action="bans_manual_menu").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_blocked_users_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="bans_export").pack(),
)
builder.button(
text="📛 Забаненные вручную",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="Забанить заранее",
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
)
builder.button(
text="🗑️ Удалить забанивших",
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
)
builder.button(
text="🗑️ Очистить забаненных",
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
text="🗑️ Очистить забанивших",
callback_data=AdminPanelCallback(action="bans_clear_blocked").pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="management").pack(),
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_shadow_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="shadow_bans_export").pack(),
)
builder.button(
text=" Забанить заранее",
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
)
builder.button(
text="🗑️ Очистить теневые баны",
callback_data=AdminPanelCallback(action="bans_clear_shadow").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_manual_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="🗑️ Очистить ручные баны",
callback_data=AdminPanelCallback(action="bans_clear_manual").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
+4 -2
View File
@@ -1,3 +1,5 @@
__all__ = ("router",)
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa
from .base import AdminClusterStates, router
from .clusters_handler import router
__all__ = ["router", "AdminClusterStates"]
+18
View File
@@ -0,0 +1,18 @@
from aiogram import Router
from aiogram.fsm.state import State, StatesGroup
router = Router()
class AdminClusterStates(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
waiting_for_days_input = State()
waiting_for_new_cluster_name = State()
waiting_for_new_server_name = State()
waiting_for_server_transfer = State()
waiting_for_cluster_transfer = State()
+452
View File
@@ -0,0 +1,452 @@
from datetime import datetime
from aiogram import F, types
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers, update_key_expiry
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.operations import renew_key_in_cluster
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .base import AdminClusterStates, router
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_cluster_management_kb,
build_manage_cluster_kb,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
cluster_name = callback_data.data
result = await session.execute(
select(Server.tariff_group)
.where(
Server.cluster_name == cluster_name,
Server.tariff_group.isnot(None),
)
.limit(1)
)
row = result.first()
tariff_group = row[0] if row else ""
result = await session.execute(select(Server.server_name).where(Server.cluster_name == cluster_name))
server_names = [row[0] for row in result.all()]
result = await session.execute(
select(func.count(func.distinct(Key.tg_id))).where(
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
)
)
user_count = result.scalar() or 0
result = await session.execute(
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
)
subscription_count = result.scalar() or 0
text = (
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
cluster_name = callback.data.split("|", 1)[1]
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
from handlers.utils import ALLOWED_GROUP_CODES
allowed = set(ALLOWED_GROUP_CODES)
lines = []
for s in cluster_servers:
tids = s.get("tariff_ids") or []
subs = s.get("tariff_subgroups") or []
if tids:
subs_str = f"{len(tids)} тариф(ов)"
elif subs:
subs_str = ", ".join(sorted(subs))
else:
subs_str = ""
grps = s.get("special_groups") or []
grps = [g for g in grps if g in allowed]
grps_str = ", ".join(sorted(grps)) if grps else ""
lines.append(f"{s.get('server_name', '?')}{subs_str} | {grps_str}")
details = "\n".join(lines) if lines else "нет серверов"
await callback.message.edit_text(
text=(
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n"
f"<blockquote>{details}</blockquote>"
),
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
):
cluster_name = callback_data.data
await state.set_state(AdminClusterStates.waiting_for_days_input)
await state.update_data(cluster_name=cluster_name)
await callback_query.message.edit_text(
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
reply_markup=build_admin_back_kb("clusters"),
)
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
add_ms = days * 86400 * 1000
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
server_rows = await session.execute(server_stmt)
server_names = [row[0] for row in server_rows.all()]
server_names.append(cluster_name)
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
keys = result.scalars().all()
if not keys:
await message.answer("❌ Нет подписок в этом кластере или сервере.")
await state.clear()
return
servers = await get_servers(session=session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await message.answer("❌ Не найдены серверы в кластере.")
await state.clear()
return
is_full_remnawave = all(str(s.get("panel_type", "")).lower() == "remnawave" for s in cluster_servers)
if is_full_remnawave:
uuids = [key.client_id for key in keys if key.client_id]
if not uuids:
await message.answer("❌ Нет валидных подписок для продления.")
await state.clear()
return
api_url = cluster_servers[0].get("api_url", "")
if not api_url:
await message.answer("❌ Не найден URL панели для кластера.")
await state.clear()
return
from panels.remnawave import RemnawaveAPI
remna = RemnawaveAPI(api_url)
try:
result_bulk = await remna.bulk_extend_expiration_date(uuids, days)
finally:
await remna.aclose()
if result_bulk is None:
await message.answer("❌ Ошибка при обращении к API панели.")
await state.clear()
return
affected = result_bulk.get("affectedRows", 0)
logger.info(f"[Cluster Extend] Bulk API: продлено {affected} подписок на {days} дней")
for key in keys:
new_expiry = key.expiry_time + add_ms
await update_key_expiry(session, key.client_id, new_expiry)
await session.commit()
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> для <b>{affected}</b> пользователей в кластере <b>{cluster_name}</b>."
)
else:
for key in keys:
new_expiry = key.expiry_time + add_ms
traffic_limit = 0
device_limit = 0
key_subgroup = None
if key.tariff_id:
tariff_result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
Tariff.id == key.tariff_id,
Tariff.is_active.is_(True),
)
)
tariff = tariff_result.first()
if tariff:
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
device_limit = int(tariff[1]) if tariff[1] is not None else 0
key_subgroup = tariff[2]
await renew_key_in_cluster(
cluster_name,
email=key.email,
client_id=key.client_id,
new_expiry_time=new_expiry,
total_gb=traffic_limit,
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
plan=key.tariff_id,
)
await update_key_expiry(session, key.client_id, new_expiry)
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
)
except ValueError:
await message.answer("❌ Введите корректное число дней.")
except Exception as e:
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
await message.answer("❌ Произошла ошибка при продлении времени.")
finally:
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
):
cluster_name = callback_data.data
await state.update_data(old_cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_cluster</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_cluster_name = message.text.strip()
if len(new_cluster_name) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_cluster_name = user_data.get("old_cluster_name")
try:
result = await session.execute(
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
)
existing_cluster = result.scalar()
if existing_cluster:
await message.answer(
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count_result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == old_cluster_name)
)
keys_count = keys_count_result.scalar()
await session.execute(
update(Server).where(Server.cluster_name == old_cluster_name).values(cluster_name=new_cluster_name)
)
if keys_count > 0:
await session.execute(
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
)
await session.commit()
await message.answer(
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_server(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
state: FSMContext,
session: AsyncSession,
):
old_server_name = callback_data.data
servers = await get_servers(session=session)
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == old_server_name:
cluster_name = c_name
break
if cluster_name:
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным в пределах кластера.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_server</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_server_name = message.text.strip()
if len(new_server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_server_name = user_data.get("old_server_name")
cluster_name = user_data.get("cluster_name")
try:
result = await session.execute(
select(Server)
.where(
Server.cluster_name == cluster_name,
Server.server_name == new_server_name,
)
.limit(1)
)
existing_server = result.scalar()
if existing_server:
await message.answer(
text=(
f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. "
f"Введите другое имя."
),
reply_markup=build_admin_back_kb("clusters"),
)
return
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
keys_count = result.scalar()
await session.execute(
update(Server)
.where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
.values(server_name=new_server_name)
)
if keys_count > 0:
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.commit()
await message.answer(
text=(
f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' "
f"в кластере '{cluster_name}'!"
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
+820
View File
@@ -0,0 +1,820 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
from aiogram import F, types
from aiogram.types import CallbackQuery
from py3xui import AsyncApi
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import MODES_CONFIG
from database import get_servers
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
)
from handlers.keys.operations.aggregated_links import make_aggregated_link
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from panels.remnawave import RemnawaveAPI
from utils.backup import create_backup_and_send_to_admins
from ..panel.keyboard import build_admin_back_kb
from .base import router
from .keyboard import AdminClusterCallback, build_availability_kb, build_sync_cluster_kb
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
async def handle_cluster_availability(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
await callback_query.message.edit_text(
text=(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
server_name = server["server_name"]
panel_type = server.get("panel_type", "3x-ui").lower()
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
try:
if panel_type == "3x-ui":
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=None,
)
await xui.login()
inbound_id = int(server["inbound_id"])
online_clients = await xui.client.online()
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
elif panel_type == "remnawave":
server_inbound_id = server.get("inbound_id")
if not server_inbound_id:
raise Exception("Не указан inbound_id сервера")
remna = RemnawaveAPI(server["api_url"])
nodes_data = await remna.get_all_nodes_with_online(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
inbound_id=server_inbound_id,
)
if nodes_data.get("error"):
raise Exception(nodes_data["error"])
online_remna_users = nodes_data["total_online"]
total_online_users += online_remna_users
nodes_info = nodes_data["nodes"]
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
seen = set()
for node_info in nodes_info:
node_name = node_info.get("name", "Unknown")
if node_name in seen:
continue
seen.add(node_name)
country_code = node_info.get("country_code", "Unknown")
online_users = node_info.get("online_users", 0)
flag = (
"".join(chr(ord(c) + 127397) for c in country_code.upper())
if country_code != "Unknown" and len(country_code) == 2
else country_code
)
result_text += f"{flag} ({node_name}): {online_users} онлайн\n"
except Exception as e:
error_text = str(e) or "Сервер недоступен"
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(
text=result_text,
reply_markup=build_availability_kb(cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
if server.get("panel_type") == "remnawave":
continue
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await create_backup_and_send_to_admins(xui)
text = (
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
async def handle_sync(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
async def handle_sync_server(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
server_name = callback_data.data
try:
server_result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
)
cluster_name = server_result.scalar()
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Сервер {server_name} не найден.",
reply_markup=build_admin_back_kb("clusters"),
)
return
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
stmt = (
select(
Server.api_url,
Server.inbound_id,
Server.server_name,
Server.panel_type,
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.tariff_id,
Key.remnawave_link,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
)
.join(Key, Server.server_name == Key.server_id)
.where(Server.server_name == server_name)
)
else:
stmt = (
select(
Server.api_url,
Server.inbound_id,
Server.server_name,
Server.panel_type,
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.tariff_id,
Key.remnawave_link,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
)
.join(Key, Server.cluster_name == Key.server_id)
.where(Server.server_name == server_name)
)
result = await session.execute(stmt)
keys_to_sync = result.mappings().all()
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
all_servers = await get_servers(session)
cluster_servers = all_servers.get(cluster_name, [])
server_info = None
for s in cluster_servers:
if s.get("server_name") == server_name:
server_info = s
break
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
tariffs_cache = {}
if tariff_ids:
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
tariffs_list = tariffs_result.scalars().all()
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
semaphore = asyncio.Semaphore(2)
for key in keys_to_sync:
try:
if key["panel_type"] == "remnawave":
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
if tariff and server_info:
subgroup = tariff.get("subgroup_title")
tid = key["tariff_id"]
has_new_binding = tid and tid in (server_info.get("tariff_ids") or [])
has_old_binding = subgroup and subgroup in (server_info.get("tariff_subgroups") or [])
has_any_binding = bool(server_info.get("tariff_ids") or server_info.get("tariff_subgroups"))
if has_any_binding and subgroup and not has_new_binding and not has_old_binding:
continue
if tariff.get("group_code") and tariff.get("group_code").lower() in ALLOWED_GROUP_CODES:
if tariff.get("group_code").lower() not in server_info.get("special_groups", []):
continue
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
)
remna = RemnawaveAPI(key["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
continue
traffic_limit_bytes = 0
hwid_limit = 0
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
success = await remna.update_user(
uuid=key["client_id"],
expire_at=expire_iso,
telegram_id=key["tg_id"],
email=f"{key['email']}@fake.local",
active_user_inbounds=[key["inbound_id"]],
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_limit,
)
if success:
try:
sub = await remna.get_subscription_by_username(key["email"])
if sub:
new_remnawave_link = sub.get("subscriptionUrl")
if new_remnawave_link:
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=None,
plan=tariff,
)
await session.execute(
update(Key)
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
.values(remnawave_link=new_remnawave_link, key=key_value)
)
await session.commit()
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
except Exception as e:
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
if not success:
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
await create_key_on_cluster(
cluster_id=server_name,
tg_id=key["tg_id"],
client_id=key["client_id"],
email=key["email"],
expiry_timestamp=key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=hwid_limit,
traffic_limit_bytes=traffic_limit_bytes,
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
else:
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
plan=key["tariff_id"],
session=session,
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
async def handle_sync_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
cluster_name = callback_data.data
try:
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
server_names = [s.get("server_name") for s in cluster_servers if s.get("server_name")]
if not server_names:
await callback_query.message.edit_text(
text=f"В кластере {cluster_name} нет серверов.",
reply_markup=build_admin_back_kb("clusters"),
)
return
result = await session.execute(
select(
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.remnawave_link,
Key.tariff_id,
Key.server_id,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
).where(Key.server_id.in_(server_names), Key.is_frozen.is_(False))
)
else:
result = await session.execute(
select(
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.remnawave_link,
Key.tariff_id,
Key.server_id,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
)
keys_to_sync = result.mappings().all()
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
only_remnawave = all(s.get("panel_type") == "remnawave" for s in cluster_servers)
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
tariffs_cache = {}
if tariff_ids:
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
tariffs_list = tariffs_result.scalars().all()
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
if only_remnawave:
batch_size = 50
total_keys = len(keys_to_sync)
processed_count = 0
for batch_start in range(0, total_keys, batch_size):
batch = keys_to_sync[batch_start : batch_start + batch_size]
batch_end = batch_start + len(batch)
logger.info(f"[Sync] Обработка батча {batch_start}-{batch_end} из {total_keys}")
async def update_remnawave_api(key):
try:
traffic_limit_bytes = 0
hwid_limit = 0
subgroup_title = None
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
else:
traffic_limit_bytes = 0
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
subgroup_title = tariff.get("subgroup_title")
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000)
.replace(tzinfo=timezone.utc)
.isoformat()
)
if use_country_selection:
user_server = None
for s in cluster_servers:
if s.get("server_name") == key["server_id"]:
user_server = s
break
if not user_server:
return {"key": key, "success": False, "error": "Server not found"}
remna = RemnawaveAPI(user_server["api_url"])
inbound_ids = [user_server["inbound_id"]] if user_server.get("inbound_id") else []
else:
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
filtered_servers = cluster_servers
if subgroup_title or (tariff and tariff.get("id")):
tid = tariff.get("id") if tariff else None
filtered_servers = [
s
for s in cluster_servers
if (tid and tid in (s.get("tariff_ids") or []))
or (subgroup_title and subgroup_title in (s.get("tariff_subgroups") or []))
]
if not filtered_servers:
filtered_servers = cluster_servers
if tariff and tariff.get("group_code"):
group_code = tariff.get("group_code").lower()
if group_code in ALLOWED_GROUP_CODES:
special_filtered = [
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
]
if special_filtered:
filtered_servers = special_filtered
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
return {"key": key, "success": False, "error": "Login failed"}
success = await remna.update_user(
uuid=key["client_id"],
expire_at=expire_iso,
telegram_id=key["tg_id"],
email=f"{key['email']}@fake.local",
active_user_inbounds=inbound_ids,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_limit,
)
if success:
sub = await remna.get_subscription_by_username(key["email"])
new_link = sub.get("subscriptionUrl") if sub else None
return {
"key": key,
"success": True,
"new_link": new_link,
"tariff": tariff,
"traffic_limit_bytes": traffic_limit_bytes,
"hwid_limit": hwid_limit,
}
else:
return {
"key": key,
"success": False,
"needs_recreate": True,
"tariff": tariff,
"traffic_limit_bytes": traffic_limit_bytes,
"hwid_limit": hwid_limit,
}
except Exception as e:
logger.error(f"[Sync] Ошибка API для {key.get('email')}: {e}")
return {"key": key, "success": False, "error": str(e)}
tasks = [update_remnawave_api(key) for key in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
bulk_updates = []
recreate_tasks = []
for result in results:
if isinstance(result, Exception):
logger.error(f"[Sync] Exception в батче: {result}")
continue
if not isinstance(result, dict):
continue
key = result.get("key")
if not key:
continue
try:
if result.get("success") and result.get("new_link"):
new_link = result["new_link"]
tariff = result.get("tariff")
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=None,
plan=tariff,
)
bulk_updates.append({
"client_id": key["client_id"],
"remnawave_link": new_link,
"key": key_value,
})
elif result.get("needs_recreate"):
recreate_tasks.append((key, result))
except Exception as e:
logger.error(f"[Sync] Ошибка подготовки для {key.get('email')}: {e}")
if bulk_updates:
try:
await session.run_sync(
lambda sync_session: sync_session.bulk_update_mappings(Key, bulk_updates)
)
await session.commit()
logger.info(f"[Sync] Bulk: обновлено {len(bulk_updates)} ключей")
except Exception as bulk_error:
logger.warning(f"[Sync] Bulk упал, fallback: {bulk_error}")
await session.rollback()
for upd in bulk_updates:
try:
await session.execute(
update(Key)
.where(Key.client_id == upd["client_id"])
.values(remnawave_link=upd["remnawave_link"], key=upd["key"])
)
await session.commit()
except Exception as e:
logger.error(f"[Sync] Fallback ошибка {upd['client_id']}: {e}")
await session.rollback()
for key, result in recreate_tasks:
try:
logger.warning(f"[Sync] Пересоздание {key['email']}")
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
await session.execute(
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
await session.commit()
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
await create_key_on_cluster(
cluster_id_for_recreate,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=result.get("hwid_limit"),
traffic_limit_bytes=result.get("traffic_limit_bytes"),
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
except Exception as e:
logger.error(f"[Sync] Пересоздание ошибка {key.get('email')}: {e}")
processed_count = batch_end
progress_percent = int((processed_count / total_keys) * 100)
progress_bar = "" * (progress_percent // 5) + "" * (20 - progress_percent // 5)
try:
await callback_query.message.edit_text(
text=(
f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n"
f"🔑 Количество ключей: <b>{total_keys}</b>\n\n"
f"Обработано: <b>{processed_count}/{total_keys}</b>\n"
f"<code>{progress_bar}</code>"
)
)
except Exception:
pass
else:
for key in keys_to_sync:
try:
traffic_limit_bytes = 0
hwid_limit = 0
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
else:
traffic_limit_bytes = 0
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
tariff.get("subgroup_title")
elif key["tariff_id"]:
logger.warning(
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']}"
f"обновим без лимитов"
)
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
await session.execute(
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
await create_key_on_cluster(
cluster_id_for_recreate,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=hwid_limit,
traffic_limit_bytes=traffic_limit_bytes,
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=(
f"✅ <b>Синхронизация завершена</b>\n\n"
f"📊 Кластер: <b>{cluster_name}</b>\n"
f"🔑 Обработано ключей: <b>{len(keys_to_sync)}</b>"
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"[Sync] Ошибка синхронизации кластера {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
+628
View File
@@ -0,0 +1,628 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy import and_, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Server, ServerSpecialgroup, ServerSubgroup, Tariff
from database.servers import has_legacy_subgroup_bindings
from filters.admin import IsAdminFilter
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from .base import router
from .keyboard import (
AdminClusterCallback,
build_attach_tariff_kb,
build_legacy_reset_kb,
build_manage_cluster_kb,
build_select_group_servers_kb,
build_select_subgroup_servers_kb,
build_tariff_group_selection_for_servers_kb,
build_tariff_group_selection_kb,
build_tariff_selection_kb,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
async def show_tariff_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession
):
cluster_name = callback_data.data
result = await session.execute(
select(Tariff.id, Tariff.group_code).where(Tariff.group_code.isnot(None)).distinct(Tariff.group_code)
)
rows = result.mappings().all()
groups = [(r["id"], r["group_code"]) for r in rows]
if not groups:
await callback.message.edit_text("❌ Нет доступных тарифных групп.")
return
await callback.message.edit_text(
f"<b>💸 Выберите тарифную группу для кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name, group_id = callback_data.data.split("|", 1)
group_id = int(group_id)
result = await session.execute(select(Tariff.group_code).where(Tariff.id == group_id))
row = result.mappings().first()
if not row:
await callback.message.edit_text("❌ Тарифная группа не найдена.")
return
group_code = row["group_code"]
await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при применении тарифной группы: {e}")
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "set_subgroup"), IsAdminFilter())
async def show_servers_for_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
server_ids = [s.get("server_id") for s in cluster_servers if s.get("server_id")]
if server_ids and await has_legacy_subgroup_bindings(session, server_ids):
await callback.message.edit_text(
f"<b>⚠️ Обнаружены привязки старого формата</b>\n\n"
f"Кластер <code>{cluster_name}</code> содержит привязки по названиям подгрупп.\n"
f"Для использования новой системы необходимо сбросить текущие привязки.\n\n"
f"<i>После сброса вы сможете привязать тарифы по ID.</i>",
reply_markup=build_legacy_reset_kb(cluster_name),
)
return
data = await state.get_data()
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_subgroup"), IsAdminFilter())
async def toggle_server_for_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_subgroup_selection"), IsAdminFilter())
async def reset_tariff_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({
f"subgrp_sel:{cluster_name}": [],
f"tariff_sel:{cluster_name}": [],
})
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_subgroup"), IsAdminFilter())
async def choose_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected_servers = set(data.get(key, []))
if not selected_servers:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
await callback.answer("Сначала установите тарифную группу для этого кластера", show_alert=True)
return
group_code = group_codes[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
)
tariffs = result.scalars().all()
if not tariffs:
await callback.message.edit_text("❌ Для этой группы нет доступных тарифов.")
return
servers_q = await session.execute(select(Server.id).where(Server.server_name.in_(selected_servers)))
server_ids = [row[0] for row in servers_q.fetchall()]
current_bindings_q = await session.execute(
select(ServerSubgroup.subgroup_title)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
)
current_tariff_ids = {int(row[0]) for row in current_bindings_q.fetchall()}
await state.update_data({f"tariff_sel:{cluster_name}": list(current_tariff_ids)})
await callback.message.edit_text(
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, current_tariff_ids),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_tariff"), IsAdminFilter())
async def toggle_tariff_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, tariff_id_str = callback_data.data.split("|", 1)
tariff_id = int(tariff_id_str)
key = f"tariff_sel:{cluster_name}"
data = await state.get_data()
selected_tariffs = set(data.get(key, []))
if tariff_id in selected_tariffs:
selected_tariffs.remove(tariff_id)
else:
selected_tariffs.add(tariff_id)
await state.update_data({key: list(selected_tariffs)})
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
return
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_codes[0], Tariff.is_active.is_(True))
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
)
tariffs = result.scalars().all()
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, selected_tariffs),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariffs"), IsAdminFilter())
async def apply_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name = callback_data.data
data = await state.get_data()
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
selected_tariffs = set(data.get(f"tariff_sel:{cluster_name}", []))
if not selected_servers:
await callback.answer("Не выбраны серверы", show_alert=True)
return
servers_q = await session.execute(
select(Server.id, Server.server_name, Server.tariff_group).where(Server.server_name.in_(selected_servers))
)
servers_data = servers_q.fetchall()
server_ids = [row[0] for row in servers_data]
group_code = servers_data[0][2] if servers_data else "standard"
if not server_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
selected_tariff_strs = {str(tid) for tid in selected_tariffs}
await session.execute(
delete(ServerSubgroup)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
.where(ServerSubgroup.subgroup_title.notin_(selected_tariff_strs))
)
for tariff_id in selected_tariffs:
tariff_id_str = str(tariff_id)
existing_q = await session.execute(
select(ServerSubgroup.server_id)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title == tariff_id_str)
)
already = {r[0] for r in existing_q.fetchall()}
to_insert = [sid for sid in server_ids if sid not in already]
if to_insert:
session.add_all([
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=tariff_id_str)
for sid in to_insert
])
await session.commit()
await state.update_data({
f"subgrp_sel:{cluster_name}": [],
f"tariff_sel:{cluster_name}": [],
})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
all_tariff_ids = set()
for s in cluster_servers:
all_tariff_ids.update(s.get("tariff_ids") or [])
tariffs_cache = {}
if all_tariff_ids:
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
for t in result.scalars().all():
tariffs_cache[t.id] = {
"id": t.id,
"name": t.name,
"subgroup_title": t.subgroup_title,
"group_code": t.group_code,
}
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при применении тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении тарифов.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_subgroups"), IsAdminFilter())
async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSubgroup).where(ServerSubgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все подгруппы тарифов сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
def render_attach_tariff_menu_text(
cluster_name: str, cluster_servers: list[dict], tariffs_cache: dict[int, dict] | None = None
) -> str:
tariff_map: dict[int, list[str]] = {}
legacy_map: dict[str, list[str]] = {}
for s in cluster_servers:
server_name = s["server_name"]
for tid in s.get("tariff_ids") or []:
tariff_map.setdefault(tid, []).append(server_name)
for sg in s.get("tariff_subgroups") or []:
legacy_map.setdefault(sg, []).append(server_name)
allowed = tuple(ALLOWED_GROUP_CODES)
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
for s in cluster_servers:
for g in s.get("special_groups") or []:
if g in spec_map:
spec_map[g].append(s["server_name"])
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
lines.append("\n<b>📋 Тарифы:</b>")
if tariff_map and tariffs_cache:
grouped: dict[str | None, list[tuple[int, str, list[str]]]] = {}
for tid, servers in tariff_map.items():
tariff = tariffs_cache.get(tid, {})
subgroup = tariff.get("subgroup_title")
name = tariff.get("name", f"ID:{tid}")
grouped.setdefault(subgroup, []).append((tid, name, servers))
tariff_lines = []
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
for subgroup in subgroups_sorted:
tariffs_list = grouped[subgroup]
if subgroup:
tariff_lines.append(f"<b>{subgroup}</b>")
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"{name}: {servers_str}")
else:
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"{name}: {servers_str}")
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
elif tariff_map:
tariff_lines = []
for tid, servers in sorted(tariff_map.items()):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"• ID:{tid}: {servers_str}")
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
if legacy_map:
lines.append("\n<b>⚠️ Старые привязки (по названию):</b>")
legacy_lines = []
for k in sorted(legacy_map):
servers_list = ", ".join(sorted(set(legacy_map[k])))
legacy_lines.append(f"• <b>{k}</b>: {servers_list}")
lines.append("<blockquote>" + "\n".join(legacy_lines) + "</blockquote>")
lines.append("<i>Рекомендуется сбросить и настроить заново</i>")
lines.append("\n<b>🎁 Спецгруппы:</b>")
has_spec = any(spec_map[k] for k in allowed)
if has_spec:
spec_lines = []
for k in allowed:
vals = sorted(set(spec_map[k]))
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else ''}")
lines.append("<blockquote>" + "\n".join(spec_lines) + "</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
return "\n".join(lines)
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
packed = AdminClusterCallback.unpack(callback.data)
cluster_name = packed.data
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
all_tariff_ids = set()
for s in cluster_servers:
all_tariff_ids.update(s.get("tariff_ids") or [])
tariffs_cache = {}
if all_tariff_ids:
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
for t in result.scalars().all():
tariffs_cache[t.id] = {
"id": t.id,
"name": t.name,
"subgroup_title": t.subgroup_title,
"group_code": t.group_code,
}
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"), IsAdminFilter())
async def show_servers_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
data = await state.get_data()
selected = set(data.get(f"grp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"), IsAdminFilter())
async def toggle_server_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"), IsAdminFilter())
async def reset_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({f"grp_sel:{cluster_name}": []})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"), IsAdminFilter())
async def choose_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
await callback.message.edit_text(
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"), IsAdminFilter())
async def apply_group_to_servers(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
groups = ALLOWED_GROUP_CODES
if i < 0 or i >= len(groups):
await callback.answer("Группа не найдена", show_alert=True)
return
group_code = groups[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
return
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
id_by_name = {name: sid for sid, name in rows.fetchall()}
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
if not server_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
exist_rows = await session.execute(
select(ServerSpecialgroup.server_id).where(
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
)
)
already = {r[0] for r in exist_rows.fetchall()}
to_insert = [sid for sid in server_ids if sid not in already]
if to_insert:
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
await session.commit()
logger.debug(f"[apply_group_to_servers] group={group_code} server_ids={server_ids}")
await state.update_data({key: []})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при назначении группы тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"), IsAdminFilter())
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
@@ -0,0 +1,98 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from config import USE_COUNTRY_SELECTION
from core.bootstrap import MODES_CONFIG
from database.models import Key, Server
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .base import router
@router.callback_query(F.data.startswith("transfer_to_server|"), IsAdminFilter())
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_server_name = data[1]
old_server_name = data[2]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.execute(
delete(Server).where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
)
await session.commit()
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
final_text = base_text + (sync_reminder if use_country_selection else "")
await callback_query.message.edit_text(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_cluster|"), IsAdminFilter())
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_cluster_name = data[1]
old_cluster_name = data[2]
old_server_name = data[3]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_cluster_name))
await session.execute(update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name))
await session.execute(
delete(Server).where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
)
await session.commit()
await callback_query.message.edit_text(
text=(
f"✅ Ключи успешно перенесены в кластер '<b>{new_cluster_name}</b>', "
f"сервер '<b>{old_server_name}</b>' и кластер '<b>{old_cluster_name}</b>' удалены!\n\n"
f'⚠️ Не забудьте сделать "Синхронизацию".'
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
+260
View File
@@ -0,0 +1,260 @@
from typing import Any
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import check_unique_server_name, get_servers
from database.models import Server
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .base import AdminClusterStates, router
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_clusters_editor_kb,
build_panel_type_kb,
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "clusters"),
IsAdminFilter(),
)
async def handle_servers(callback_query: CallbackQuery, session: AsyncSession):
servers = await get_servers(session, include_enabled=True)
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<blockquote>"
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
"</blockquote>\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
message = callback_query.message
markup = build_clusters_editor_kb(servers)
if message and message.text:
await message.edit_text(text=text, reply_markup=markup)
else:
try:
await message.delete()
except Exception:
pass
await message.answer(text=text, reply_markup=markup)
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя должно быть уникальным!</b>\n"
"<b>Имя не должно превышать 12 символов!</b>\n\n"
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: Message, state: FSMContext):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
if len(message.text) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
server_name = message.text.strip()
if len(server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
if not await check_unique_server_name(session, server_name, cluster_name):
await message.answer(
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
"️ <b>Формат для 3X-UI:</b>\n"
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
"️ <b>Формат для Remnawave:</b>\n"
"<code>https://your-domain.com/api</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_api_url)
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Если вы используете Remnawave — введите <code>0</code>\n\n"
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
raw = message.text.strip()
subscription_url = None if raw == "0" else raw.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
await message.answer(
text=f"<b>Введите inbound_id/Squads для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Squads, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
await state.update_data(inbound_id=inbound_id)
await message.answer(
text=(
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
"Поддержка режима выбора стран — <b>ограничена</b>."
),
reply_markup=build_panel_type_kb(),
)
@router.callback_query(
AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])),
IsAdminFilter(),
)
async def handle_panel_type_selection(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
session: AsyncSession,
):
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
inbound_id = user_data.get("inbound_id")
result = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1))
row = result.first()
tariff_group = row[0] if row else None
new_server = Server(
cluster_name=cluster_name,
server_name=server_name,
api_url=api_url,
subscription_url=subscription_url,
inbound_id=inbound_id,
panel_type=panel_type,
tariff_group=tariff_group,
)
session.add(new_server)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
File diff suppressed because it is too large Load Diff
+97 -15
View File
@@ -4,6 +4,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
from handlers.buttons import BACK
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
@@ -65,7 +66,7 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
@@ -75,6 +76,16 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📋 Привязать тарифы",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🗂 Спецгруппы",
@@ -87,18 +98,25 @@ def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
)
builder.row(
InlineKeyboardButton(
text="🗂 Подгруппы",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
),
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_legacy_reset_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🧹 Сбросить",
text="🧹 Сбросить привязки",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
text=BACK,
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
return builder.as_markup()
@@ -128,7 +146,7 @@ def build_select_subgroup_servers_kb(
builder.row(
InlineKeyboardButton(
text="📚 Выбрать подгруппу",
text="📋 Выбрать тарифы",
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
)
)
@@ -140,8 +158,8 @@ def build_select_subgroup_servers_kb(
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
text=BACK,
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
@@ -165,6 +183,52 @@ def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str])
return builder.as_markup()
def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[int]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
grouped: dict[str | None, list] = {}
for t in tariffs:
subgroup = t.subgroup_title
grouped.setdefault(subgroup, []).append(t)
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
for subgroup in subgroups_sorted:
tariffs_list = grouped[subgroup]
if subgroup:
builder.row(
InlineKeyboardButton(
text=f"━━ {subgroup} ━━",
callback_data="noop",
)
)
for t in tariffs_list:
mark = "" if t.id in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {t.name}",
callback_data=AdminClusterCallback(action="toggle_tariff", data=f"{cluster_name}|{t.id}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✅ Применить",
callback_data=AdminClusterCallback(action="apply_tariffs", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад к выбору серверов",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
@@ -210,7 +274,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
builder.row(InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
@@ -258,7 +322,7 @@ def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, s
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
@@ -300,7 +364,7 @@ def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, sele
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
@@ -320,9 +384,27 @@ def build_tariff_group_selection_for_servers_kb(
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
text=BACK,
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_availability_kb(cluster_name: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🔁 Обновить",
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
)
],
[
InlineKeyboardButton(
text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
)
],
]
)
+203 -41
View File
@@ -28,14 +28,15 @@ from .keyboard import (
format_coupons_list,
)
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_coupon_audience = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
waiting_for_percent_data = State()
@router.callback_query(
@@ -55,6 +56,7 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
kb = InlineKeyboardBuilder()
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
kb.button(text="⏳ Время", callback_data="coupon_type_days")
kb.button(text="📉 Процент", callback_data="coupon_type_percent")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
@@ -62,34 +64,82 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
)
async def show_coupon_audience_step(callback_query: CallbackQuery, state: FSMContext):
text = "🎯 <b>Кому доступен купон?</b>"
kb = InlineKeyboardBuilder()
kb.button(text="👤 Всем", callback_data="coupon_audience_all")
kb.button(text="🆕 Только новым", callback_data="coupon_audience_new")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
await state.set_state(AdminCouponsState.waiting_for_coupon_audience)
@router.callback_query(F.data == "coupon_type_days")
@router.callback_query(F.data == "coupon_type_balance", IsAdminFilter())
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="balance")
await show_coupon_audience_step(callback_query, state)
@router.callback_query(F.data == "coupon_type_days", IsAdminFilter())
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="days", new_users_only=False)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
"Пример: <b>'DAYS10 10 50'</b>\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_days_data)
@router.callback_query(F.data == "coupon_type_percent", IsAdminFilter())
async def handle_percent_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="percent", new_users_only=False)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 📉 <i>процент</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'SALE20 20 10'</b>\n"
"Где 20 — это скидка 20%\n\n"
)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_percent_data)
@router.callback_query(F.data.in_(("coupon_audience_all", "coupon_audience_new")), IsAdminFilter())
async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
coupon_type = data.get("coupon_type")
if coupon_type != "balance":
await callback_query.answer("Ошибка: режим доступен только для купонов на баланс", show_alert=True)
return
await state.update_data(new_users_only=callback_query.data == "coupon_audience_new")
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b>\n\n"
)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
@@ -97,12 +147,13 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"❌ <b>Некорректный формат!</b>\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
"Пример: <b>'COUPON1 50 5'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
@@ -112,20 +163,39 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
coupon_amount = int(parts[1])
usage_limit = int(parts[2])
if coupon_amount <= 0:
raise ValueError("Сумма должна быть больше 0")
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
text = "⚠️ <b>Проверьте данные!</b>\nСумма и лимит должны быть целыми числами больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
data = await state.get_data()
new_users_only = bool(data.get("new_users_only"))
ok = await create_coupon(
session,
coupon_code,
coupon_amount,
usage_limit,
days=None,
new_users_only=new_users_only,
percent=None,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"✅ Купон <b>{coupon_code}</b> создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
f"🎯 Доступ: <b>{audience_txt}</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
@@ -137,7 +207,6 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@@ -150,12 +219,11 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
"Пример: <b>'DAYS10 10 50'</b> 👈"
"❌ <b>Некорректный формат!</b>\n🏷️ <b>код</b> ⏳ <b>дни</b> 🔢 <b>лимит</b>\nПример: <b>'DAYS10 10 50'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
@@ -165,20 +233,34 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
days = int(parts[1])
usage_limit = int(parts[2])
if days <= 0:
raise ValueError("Количество дней должно быть больше 0")
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
text = "⚠️ <b>Проверьте данные!</b>\nДни и лимит должны быть целыми числами больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
ok = await create_coupon(
session,
coupon_code,
0,
usage_limit,
days=days,
new_users_only=False,
percent=None,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"✅ Купон <b>{coupon_code}</b> создан!\n"
f"⏳ <b>{format_days(days)}</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
@@ -190,7 +272,66 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.message(AdminCouponsState.waiting_for_percent_data, IsAdminFilter())
async def handle_percent_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b>\n"
"🏷️ <b>код</b> 📉 <b>процент</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'SALE20 20 10'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
percent = int(parts[1])
usage_limit = int(parts[2])
if percent <= 0 or percent > 100:
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте данные!</b>\nПроцент должен быть 1..100, лимит — целое число больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
ok = await create_coupon(
session,
coupon_code,
0,
usage_limit,
days=None,
new_users_only=False,
percent=percent,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
text = (
f"✅ Купон <b>{coupon_code}</b> создан!\n📉 Скидка: <b>{percent}%</b>\n🔢 Лимит: <b>{usage_limit} раз</b>\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@@ -286,7 +427,6 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
return
coupon_code = inline_query.query.split("coupon_")[1]
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
@@ -300,17 +440,39 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
)
return
percent_value = coupon.get("percent")
if percent_value is not None and int(percent_value) > 0:
await inline_query.answer(
results=[],
switch_pm_text="Процентные купоны не публикуются ссылкой",
switch_pm_parameter="coupons",
cache_time=1,
)
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
title = f"Купон {coupon['code']}"
description = (
f"Получи {coupon['amount']} рублей!"
if coupon["amount"] > 0
else f"Продли подписку на {format_days(coupon['days'])}!"
)
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
f"👇 Нажми, чтобы активировать!"
)
days_value = coupon.get("days")
amount_value = coupon.get("amount") or 0
if days_value is not None and int(days_value) > 0:
days_int = int(days_value)
description = f"Продли подписку на {format_days(days_int)}!"
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"⏳ <b>Продление:</b> {format_days(days_int)}\n"
f"👇 Нажми, чтобы активировать!"
)
elif int(amount_value) > 0:
amount_int = int(amount_value)
description = f"Получи {amount_int} рублей!"
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n💰 <b>Бонус:</b> {amount_int} рублей\n👇 Нажми, чтобы активировать!"
)
else:
description = "Купон"
message_text = f"🎫 <b>Купон:</b> {coupon['code']}\n👇 Нажми, чтобы активировать!"
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
+26 -14
View File
@@ -67,18 +67,30 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = (
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
if coupon["amount"] > 0
else f"⏳ <b>{format_days(coupon['days'])}</b>"
text = "📜 <b>Список купонов</b>\n\n"
for i, coupon in enumerate(coupons, start=1):
percent_value = coupon.get("percent")
days_value = coupon.get("days")
amount_value = coupon.get("amount") or 0
if percent_value is not None and int(percent_value) > 0:
value_line = f"📉 <b>Скидка:</b> {int(percent_value)}%"
elif days_value is not None and int(days_value) > 0:
value_line = f"⏳ <b>Продление:</b> {format_days(int(days_value))}"
elif int(amount_value) > 0:
value_line = f"💰 <b>Баланс:</b> {int(amount_value)}"
else:
value_line = ""
text += (
f"<blockquote>"
f"<b>{i}. {coupon['code']}</b>\n"
f"{value_line}\n"
f"🔢 <b>Лимит:</b> {coupon['usage_limit']} | "
f"✅ <b>Использовано:</b> {coupon['usage_count']}\n"
f"<code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>"
f"</blockquote>\n\n"
)
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"{value_text}\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
)
return coupon_list
return text
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .emoji_handler import router
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
from typing import Iterable
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.enums import MessageEntityType
from aiogram.types import CallbackQuery, Message, MessageEntity
from aiogram.utils.keyboard import InlineKeyboardBuilder
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
class AdminEmojiState(StatesGroup):
waiting_for_custom_emoji = State()
router = Router()
def _build_back_to_emoji_menu() -> InlineKeyboardBuilder:
builder = InlineKeyboardBuilder()
builder.button(text=BACK, callback_data=AdminPanelCallback(action="emoji").pack())
builder.adjust(1)
return builder
def _extract_custom_emoji_ids(entities: Iterable[MessageEntity]) -> list[str]:
ids: list[str] = []
for ent in entities:
if ent.type == MessageEntityType.CUSTOM_EMOJI and ent.custom_emoji_id:
ids.append(ent.custom_emoji_id)
return ids
def _utf16_len(text: str) -> int:
return len(text.encode("utf-16-le")) // 2
@router.callback_query(AdminPanelCallback.filter(F.action == "emoji"), IsAdminFilter())
async def show_emoji_menu(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminEmojiState.waiting_for_custom_emoji)
example_id = "5201769509345588200"
marker = f"{{{{emoji:{example_id}}}}}"
preview_placeholder = "😀"
text = (
"Отправьте любое кастомное эмоджи — я верну его ID и покажу пример для текстов.\n\n"
"Пример:\n"
f"{preview_placeholder}"
)
entities: list[MessageEntity] = []
start = 0
while True:
marker_pos = text.find(marker, start)
if marker_pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(text[:marker_pos]),
length=_utf16_len(marker),
)
)
start = marker_pos + len(marker)
preview_pos = text.find(preview_placeholder)
if preview_pos != -1:
entities.append(
MessageEntity(
type=MessageEntityType.CUSTOM_EMOJI,
offset=_utf16_len(text[:preview_pos]),
length=_utf16_len(preview_placeholder),
custom_emoji_id=example_id,
)
)
if hasattr(callback_query.message, "_original_edit_text"):
await callback_query.message._original_edit_text(
text=text,
entities=entities,
reply_markup=build_admin_back_kb("admin"),
parse_mode=None,
)
else:
await callback_query.message.edit_text(
text=text,
entities=entities,
reply_markup=build_admin_back_kb("admin"),
parse_mode=None,
)
@router.message(AdminEmojiState.waiting_for_custom_emoji, IsAdminFilter())
async def handle_custom_emoji_id(message: Message, state: FSMContext):
entities = list(message.entities or []) + list(message.caption_entities or [])
emoji_ids = _extract_custom_emoji_ids(entities)
if not emoji_ids:
await message.answer(
"❌ Не вижу кастомных эмоджи. Отправьте именно <b>кастомный эмоджи</b> из набора.",
reply_markup=_build_back_to_emoji_menu().as_markup(),
)
return
unique_ids: list[str] = []
for emoji_id in emoji_ids:
if emoji_id not in unique_ids:
unique_ids.append(emoji_id)
placeholder = "😀"
back_builder = _build_back_to_emoji_menu()
if len(unique_ids) == 1:
emoji_id = unique_ids[0]
marker = f"{{{{emoji:{emoji_id}}}}}"
example_send = f"Ты отправил: Привет, {marker} !"
example_recv = f"А получил: Привет, {placeholder} !"
instruction_text = (
"✅ ID кастомного эмоджи\n"
f"{emoji_id}\n\n"
"Вставляйте в файл текстов так:\n"
f"{marker}\n\n"
"Пример:\n"
f"{example_send}\n"
f"{example_recv}\n\n"
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
)
preview_text = example_recv
preview_ids = [emoji_id]
code_markers = [marker]
else:
ids_text = "\n".join(f"{emoji_id}" for emoji_id in unique_ids)
markers_text = "\n".join(f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
markers_inline = " ".join(f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
example_send = f"Ты отправил: Привет, {markers_inline} !"
example_recv = "А получил: Привет, " + " ".join(placeholder for _ in unique_ids) + " !"
instruction_text = (
"✅ ID кастомных эмоджи\n"
f"{ids_text}\n\n"
"Вставляйте в файл текстов так:\n"
f"{markers_text}\n\n"
"Пример:\n"
f"{example_send}\n"
f"{example_recv}\n\n"
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
)
preview_text = example_recv
preview_ids = unique_ids
code_markers = [f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids]
full_text = instruction_text
entities: list[MessageEntity] = []
for marker in code_markers:
start = 0
while True:
pos = full_text.find(marker, start)
if pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(full_text[:pos]),
length=_utf16_len(marker),
)
)
start = pos + len(marker)
for emoji_id in unique_ids:
start = 0
while True:
pos = full_text.find(emoji_id, start)
if pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(full_text[:pos]),
length=_utf16_len(emoji_id),
)
)
start = pos + len(emoji_id)
preview_offset_base = _utf16_len(full_text[: full_text.index(preview_text)])
running_utf16 = 0
idx = 0
for ch in preview_text:
if ch == placeholder and idx < len(preview_ids):
entities.append(
MessageEntity(
type=MessageEntityType.CUSTOM_EMOJI,
offset=preview_offset_base + running_utf16,
length=_utf16_len(placeholder),
custom_emoji_id=str(preview_ids[idx]),
)
)
idx += 1
running_utf16 += _utf16_len(ch)
if hasattr(message, "_original_answer"):
await message._original_answer(
text=full_text,
entities=entities,
reply_markup=back_builder.as_markup(),
parse_mode=None,
)
else:
await message.answer(text=full_text, reply_markup=back_builder.as_markup(), parse_mode=None)
await state.clear()
+24 -48
View File
@@ -8,13 +8,15 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift, GiftUsage, Tariff
from filters.admin import IsAdminFilter
from database.models import Gift, GiftUsage
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
from handlers.buttons import BACK
router = Router()
@@ -25,14 +27,14 @@ class GiftCreationState(StatesGroup):
waiting_for_limit_input_or_unlimited = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"))
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"), IsAdminFilter())
async def admin_gift_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:", reply_markup=build_admin_gifts_kb()
)
@router.callback_query(F.data == "admin_gift_create")
@router.callback_query(F.data == "admin_gift_create", IsAdminFilter())
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
@@ -40,7 +42,7 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
if not tariffs:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
@@ -73,12 +75,12 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
)
)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
builder.row(types.InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("admin_gift_subgroup|"))
@router.callback_query(F.data.startswith("admin_gift_subgroup|"), IsAdminFilter())
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
try:
subgroup_hash = callback.data.split("|", 1)[1]
@@ -108,7 +110,7 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
)
)
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
builder.row(types.InlineKeyboardButton(text=BACK, callback_data="admin_gift_create"))
await edit_or_send_message(
target_message=callback.message,
@@ -121,20 +123,20 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
@router.callback_query(F.data.startswith("admin_gift_select|"))
@router.callback_query(F.data.startswith("admin_gift_select|"), IsAdminFilter())
async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
tariff_id = int(callback.data.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(GiftCreationState.waiting_for_limit_input_or_unlimited)
kb = InlineKeyboardBuilder()
kb.button(text="🔙 Назад", callback_data="admin_gift_create")
kb.button(text=BACK, callback_data="admin_gift_create")
await callback.message.edit_text(
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
)
@router.callback_query(F.data == "gift_limit_unlimited")
@router.callback_query(F.data == "gift_limit_unlimited", IsAdminFilter())
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
@@ -144,7 +146,7 @@ async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot:
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited, IsAdminFilter())
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
@@ -162,12 +164,12 @@ async def handle_limited_gift_input(message: types.Message, session: AsyncSessio
await finalize_gift(message, session, bot, data, is_unlimited=False)
@router.callback_query(F.data == "admin_gifts_all")
@router.callback_query(F.data == "admin_gifts_all", IsAdminFilter())
async def show_gifts_page(callback: CallbackQuery, session: AsyncSession):
await show_gift_list(callback, session, page=1)
@router.callback_query(F.data.startswith("gifts_page|"))
@router.callback_query(F.data.startswith("gifts_page|"), IsAdminFilter())
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
page = int(callback.data.split("|")[1])
await show_gift_list(callback, session, page)
@@ -181,47 +183,21 @@ async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: i
result = await session.execute(stmt)
gifts = result.scalars().all()
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
builder = InlineKeyboardBuilder()
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
builder.inline_keyboard.extend(keyboard.inline_keyboard)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
await callback.message.edit_text(
f"🎁 <b>Список подарков</b>\nСтраница {page}:",
reply_markup=keyboard,
)
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
limit = 10
offset = (page - 1) * limit
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(stmt)
gifts = result.scalars().all()
builder = InlineKeyboardBuilder()
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
for row in keyboard.inline_keyboard:
builder.row(*row)
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("gift_view|"))
@router.callback_query(F.data.startswith("gift_view|"), IsAdminFilter())
async def view_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
@@ -254,12 +230,12 @@ async def view_gift(callback: CallbackQuery, session: AsyncSession):
builder = InlineKeyboardBuilder()
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
builder.button(text="🔙 Назад", callback_data="admin_gifts_all")
builder.button(text=BACK, callback_data="admin_gifts_all")
await callback.message.edit_text(text, reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("gift_delete|"))
@router.callback_query(F.data.startswith("gift_delete|"), IsAdminFilter())
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
+5 -8
View File
@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.models import Gift, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days, format_months
from handlers.utils import format_days
from ..panel.keyboard import AdminPanelCallback
@@ -27,7 +27,7 @@ def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
text=f"{tariff.name}{tariff.duration_days // 30} мес.",
callback_data=f"admin_gift_confirm|{tariff.id}",
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
builder.adjust(1)
return builder.as_markup()
@@ -37,11 +37,8 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
row = []
for i, gift in enumerate(gifts):
if gift.selected_months > 0:
duration_text = format_months(gift.selected_months)
else:
days = (gift.expiry_time.date() - gift.created_at.date()).days
duration_text = format_days(days)
days = (gift.expiry_time.date() - gift.created_at.date()).days
duration_text = format_days(days)
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
@@ -64,6 +61,6 @@ def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeybo
if nav:
builder.row(*nav)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
builder.row(InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack()))
return builder.as_markup()
+13 -2
View File
@@ -1,3 +1,14 @@
__all__ = ("router",)
from aiogram import Router
from .management_handler import router
router = Router()
from . import (
admins, # noqa: F401
database, # noqa: F401
domain, # noqa: F401
file_upload, # noqa: F401
import_3xui, # noqa: F401
import_remnawave, # noqa: F401
maintenance, # noqa: F401
)
+154
View File
@@ -0,0 +1,154 @@
import hashlib
from asyncio import sleep
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Admin
from filters.admin import IsAdminFilter
from . import router
from .keyboard import (
AdminPanelCallback,
build_admin_back_kb_to_admins,
build_admins_kb,
build_role_selection_kb,
build_single_admin_menu,
build_token_result_kb,
)
class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"), IsAdminFilter())
async def show_admins(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Admin.tg_id, Admin.role))
admins = result.all()
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"), IsAdminFilter())
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
)
await state.set_state(AdminState.waiting_for_tg_id)
@router.message(AdminState.waiting_for_tg_id, IsAdminFilter())
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
try:
tg_id = int(message.text.strip())
except ValueError:
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if result.scalar_one_or_none():
await message.answer("⚠️ Такой админ уже существует.")
else:
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
await session.commit()
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")), IsAdminFilter())
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
role = admin or "moderator"
await callback.message.edit_text(
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")), IsAdminFilter())
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
token = Admin.generate_token()
token_hash = hashlib.sha256(token.encode()).hexdigest()
admin.token = token_hash
await session.commit()
msg = await callback.message.edit_text(
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
f"<code>{token}</code>\n\n"
f"⚠️ Это сообщение исчезнет через 5 минут.",
reply_markup=build_token_result_kb(token),
)
await sleep(300)
try:
await msg.delete()
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")), IsAdminFilter())
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
tg_id = int(callback_data.action.split("|")[1])
await callback.message.edit_text(
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")), IsAdminFilter())
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
try:
_, tg_id_str, role = callback_data.action.split("|")
tg_id = int(tg_id_str)
if role not in ("superadmin", "moderator"):
raise ValueError
except Exception:
await callback.message.edit_text("❌ Неверный формат.")
return
if tg_id == callback.from_user.id:
await callback.message.edit_text(
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
)
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
admin.role = role
await session.commit()
await callback.message.edit_text(
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")), IsAdminFilter())
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
await session.commit()
await callback.message.edit_text(
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
)
+169
View File
@@ -0,0 +1,169 @@
import os
import subprocess
import sys
import traceback
from tempfile import NamedTemporaryFile
from aiogram import Bot, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from filters.admin import IsAdminFilter
from logger import logger
from . import router
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_database_kb, build_export_db_sources_kb
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "database"), IsAdminFilter())
async def handle_database_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🗄 <b>Управление базой данных</b>",
reply_markup=build_database_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"), IsAdminFilter())
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
"⚠️ Все текущие данные будут перезаписаны.",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(DatabaseState.waiting_for_backup_file)
@router.message(DatabaseState.waiting_for_backup_file, IsAdminFilter())
async def restore_database(message: Message, state: FSMContext, bot: Bot):
document = message.document
if not document or not document.file_name.endswith(".sql"):
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
return
try:
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
tmp_path = tmp_file.name
await bot.download(document, destination=tmp_path)
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
is_custom_dump = False
with open(tmp_path, "rb") as f:
signature = f.read(5)
if signature == b"PGDMP":
is_custom_dump = True
subprocess.run(
[
"sudo",
"-u",
"postgres",
"psql",
"-d",
"postgres",
"-c",
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
check=True,
)
logger.info("[Restore] База данных пересоздана")
os.environ["PGPASSWORD"] = DB_PASSWORD
if is_custom_dump:
result = subprocess.run(
[
"pg_restore",
f"--dbname={DB_NAME}",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"--no-owner",
"--exit-on-error",
tmp_path,
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[
"psql",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-d",
DB_NAME,
"-f",
tmp_path,
],
capture_output=True,
text=True,
)
del os.environ["PGPASSWORD"]
if result.returncode != 0:
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
await message.answer(
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
)
return
await message.answer(
"✅ База данных восстановлена.",
reply_markup=build_back_to_db_menu(),
)
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
await state.clear()
sys.exit(0)
except Exception as e:
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
await message.answer(
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
)
finally:
try:
os.remove(tmp_path)
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"), IsAdminFilter())
async def handle_export_db(callback: CallbackQuery):
await callback.message.edit_text(
"📤 Выберите панель, с которой требуется получить данные:\n\n"
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
reply_markup=build_export_db_sources_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"), IsAdminFilter())
async def back_to_database_menu(callback: CallbackQuery):
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
+79
View File
@@ -0,0 +1,79 @@
import re
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from . import router
from .keyboard import AdminPanelCallback
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminManagementStates.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
@router.message(AdminManagementStates.waiting_for_new_domain, IsAdminFilter())
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
new_domain = message.text.strip()
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
new_domain_url = f"https://{new_domain}"
try:
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
await session.execute(stmt)
await session.commit()
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
example = sample.fetchone()
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(
f"✅ Домен успешно изменен на {new_domain}!",
reply_markup=build_admin_back_kb("admin"),
)
await state.clear()
+108
View File
@@ -0,0 +1,108 @@
import os
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from . import router
from .keyboard import AdminPanelCallback
class FileUploadState(StatesGroup):
waiting_for_target = State()
waiting_for_file = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"), IsAdminFilter())
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
text = (
"📤 <b>Загрузка файла</b>\n\n"
"Вы можете заменить файл в корневой директории бота или в папке <code>handlers</code>.\n\n"
"📁 Выберите директорию, а затем отправьте файл с таким же именем и расширением, "
"как у уже существующего файла. Он будет автоматически заменён."
)
back_kb = build_admin_back_kb("management")
kb = InlineKeyboardBuilder()
kb.button(text="📁 Корень бота", callback_data="upload_target:root")
kb.button(text="📂 Папка handlers", callback_data="upload_target:handlers")
for row in back_kb.inline_keyboard:
kb.row(*row)
await callback.message.edit_text(
text,
reply_markup=kb.as_markup(),
)
await state.set_state(FileUploadState.waiting_for_target)
@router.callback_query(F.data.startswith("upload_target:"), FileUploadState.waiting_for_target, IsAdminFilter())
async def select_upload_target(callback: CallbackQuery, state: FSMContext):
target = callback.data.split(":", 1)[1]
if target not in {"root", "handlers"}:
await callback.answer("Неизвестная директория.")
return
await state.update_data(upload_target=target)
target_text = "Корень бота" if target == "root" else "Папка handlers"
await callback.message.edit_text(
"📤 <b>Загрузка файла</b>\n\n"
f"Выбрана директория: <b>{target_text}</b>.\n\n"
"Теперь отправьте файл с таким же именем и расширением, как у уже существующего файла. "
"Он будет автоматически заменён.",
reply_markup=build_admin_back_kb("management"),
)
await state.set_state(FileUploadState.waiting_for_file)
@router.message(FileUploadState.waiting_for_file, F.document, IsAdminFilter())
async def handle_admin_file_upload(message: Message, state: FSMContext):
document = message.document
file_name = document.file_name
if not file_name or "." not in file_name:
await message.answer("❌ У файла должно быть имя с расширением.")
return
data = await state.get_data()
target = data.get("upload_target", "root")
if target == "handlers":
base_dir = os.path.abspath("./handlers")
else:
base_dir = os.path.abspath(".")
os.makedirs(base_dir, exist_ok=True)
dest_path = os.path.join(base_dir, file_name)
try:
await message.bot.download(document, destination=dest_path)
back_kb = build_admin_back_kb("management")
kb = InlineKeyboardBuilder()
kb.button(
text="🔁 Перезагрузить бота",
callback_data=AdminPanelCallback(action="restart").pack(),
)
for row in back_kb.inline_keyboard:
kb.row(*row)
await message.answer(
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён в директории <code>{target}</code>.\n\n"
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
reply_markup=kb.as_markup(),
)
except Exception as e:
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
await message.answer(
f"❌ Не удалось сохранить файл: {e}",
reply_markup=build_admin_back_kb("management"),
)
await state.clear()
+89
View File
@@ -0,0 +1,89 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from database.models import Key
from handlers.keys.operations import update_subscription
from logger import logger
from . import router
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_post_import_kb
class Import3xuiStates(StatesGroup):
waiting_for_file = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"), IsAdminFilter())
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(Import3xuiStates.waiting_for_file)
@router.message(Import3xuiStates.waiting_for_file, F.document, IsAdminFilter())
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
file = message.document
if not file.file_name.endswith(".db"):
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
return
file_path = f"/tmp/{file.file_name}"
await message.bot.download(file, destination=file_path)
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
try:
from database.importer import import_keys_from_3xui_db
imported, skipped = await import_keys_from_3xui_db(file_path, session)
await processing_message.edit_text(
f"✅ Восстановление завершено:\n"
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
reply_markup=build_post_import_kb(),
)
except Exception as e:
logger.error(f"[Import 3x-ui] Ошибка: {e}")
await processing_message.edit_text(
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
reply_markup=build_back_to_db_menu(),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"), IsAdminFilter())
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
await callback.answer("🔁 Начинаю перевыпуск подписок...")
result = await session.execute(select(Key.tg_id, Key.email))
keys = result.all()
success = 0
failed = 0
for tg_id, email in keys:
try:
await update_subscription(tg_id=tg_id, email=email, session=session)
success += 1
except Exception as e:
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
failed += 1
await callback.message.edit_text(
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
reply_markup=build_back_to_db_menu(),
)
@@ -0,0 +1,196 @@
import json
import re
import time
from datetime import datetime
from aiogram import F
from aiogram.types import CallbackQuery
from dateutil import parser
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Key, Server, User
from filters.admin import IsAdminFilter
from logger import logger
from panels.remnawave import RemnawaveAPI
from . import router
from .keyboard import AdminPanelCallback, build_back_to_db_menu
def extract_tg_id_from_username(value: str | None) -> int | None:
if not value:
return None
value = value.strip()
match = re.search(r"_(\d+)(?:\D|$)", value)
if not match:
return None
tg_id = int(match.group(1))
if tg_id <= 0:
return None
return tg_id
def extract_tg_id_from_user_payload(user: dict) -> int | None:
tg_id = user.get("telegramId")
if isinstance(tg_id, int):
if tg_id > 0:
return tg_id
return None
if isinstance(tg_id, str):
tg_id = tg_id.strip()
if tg_id.isdigit():
tg_id_int = int(tg_id)
return tg_id_int if tg_id_int > 0 else None
tg_id = extract_tg_id_from_username(user.get("username")) or extract_tg_id_from_username(user.get("email"))
return tg_id
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"), IsAdminFilter())
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
servers = result.scalars().all()
if not servers:
await callback.message.edit_text(
"❌ Нет доступных Remnawave-серверов.",
reply_markup=build_back_to_db_menu(),
)
return
server = servers[0]
api = RemnawaveAPI(base_url=server.api_url)
users = await api.get_all_users_time(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
)
if not users:
await callback.message.edit_text(
"📭 На панели нет клиентов.",
reply_markup=build_back_to_db_menu(),
)
return
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
added_users = await import_remnawave_users(session, users)
server_id = server.cluster_name or server.server_name
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
preview = ""
for i, user in enumerate(users[:3], 1):
email = user.get("email") or user.get("username") or "-"
expire = (user.get("expireAt") or "")[:10]
preview += f"{i}. {email} — до {expire}\n"
await callback.message.edit_text(
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
f"<b>Первые 3:</b>\n{preview}",
reply_markup=build_back_to_db_menu(),
)
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
added = 0
for user in users:
tg_id = extract_tg_id_from_user_payload(user)
if not tg_id:
continue
exists = await session.execute(select(User).where(User.tg_id == tg_id))
if exists.scalar():
continue
try:
new_user = User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
session.add(new_user)
added += 1
except SQLAlchemyError as e:
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
continue
await session.commit()
return added
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
added = 0
for user in users:
tg_id = extract_tg_id_from_user_payload(user)
client_id = user.get("uuid")
email = user.get("email") or user.get("username")
remnawave_link = user.get("subscriptionUrl")
expire_at = user.get("expireAt")
created_at = user.get("createdAt")
if not tg_id or not client_id:
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
continue
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
if exists_stmt.scalar():
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
continue
try:
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_ts,
expiry_time=expire_ts,
key="",
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
session.add(new_key)
added += 1
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
except Exception as e:
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
await session.commit()
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
return added
+13 -18
View File
@@ -1,7 +1,8 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from middlewares import maintenance
from core.bootstrap import MANAGEMENT_CONFIG
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
@@ -39,7 +40,9 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
text="📤 Загрузить файл",
callback_data=AdminPanelCallback(action="upload_file").pack(),
)
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
builder.button(
text=maintenance_text,
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
@@ -72,7 +75,8 @@ def build_database_kb() -> InlineKeyboardMarkup:
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
@@ -81,7 +85,7 @@ def build_export_db_sources_kb() -> InlineKeyboardMarkup:
builder.button(text="🌀 Remnawave", callback_data=AdminPanelCallback(action="export_remnawave").pack())
builder.button(text="🧩 3x-ui", callback_data=AdminPanelCallback(action="request_3xui_file").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
@@ -105,7 +109,6 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
builder = InlineKeyboardBuilder()
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
if role == "superadmin":
@@ -113,7 +116,7 @@ def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboa
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
@@ -123,14 +126,14 @@ def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
@@ -138,24 +141,16 @@ def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
return builder.as_markup()
def build_post_import_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔁 Перевыпустить подписки", callback_data=AdminPanelCallback(action="resync_after_import").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.button(text=BACK, callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
+52
View File
@@ -0,0 +1,52 @@
from aiogram import F
from aiogram.types import CallbackQuery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.bootstrap import MANAGEMENT_CONFIG, update_management_config
from database.models import Admin
from filters.admin import IsAdminFilter
from . import router
from .keyboard import AdminPanelCallback, build_management_kb
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
tg_id = callback_query.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
return
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(admin.role),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"), IsAdminFilter())
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.answer("❌ Админ не найден.", show_alert=True)
return
current_config = dict(MANAGEMENT_CONFIG)
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
new_value = not current_value
current_config["MAINTENANCE_ENABLED"] = new_value
await update_management_config(session, current_config)
new_status = "включён" if new_value else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
@@ -1,687 +0,0 @@
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import traceback
from asyncio import sleep
from datetime import datetime
from tempfile import NamedTemporaryFile
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from dateutil import parser
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Admin, Key, Server, User
from filters.admin import IsAdminFilter
from handlers.keys.operations import update_subscription
from logger import logger
from middlewares import maintenance
from panels.remnawave import RemnawaveAPI
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminPanelCallback,
build_admin_back_kb_to_admins,
build_admins_kb,
build_back_to_db_menu,
build_database_kb,
build_export_db_sources_kb,
build_management_kb,
build_post_import_kb,
build_role_selection_kb,
build_single_admin_menu,
build_token_result_kb,
)
router = Router()
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
class Import3xuiStates(StatesGroup):
waiting_for_file = State()
class FileUploadState(StatesGroup):
waiting_for_file = State()
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
tg_id = callback_query.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
return
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(admin.role),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminManagementStates.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
new_domain_url = f"https://{new_domain}"
try:
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
await session.execute(stmt)
await session.commit()
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
example = sample.fetchone()
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(
f"✅ Домен успешно изменен на {new_domain}!",
reply_markup=build_admin_back_kb("admin"),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.answer("❌ Админ не найден.", show_alert=True)
return
maintenance.maintenance_mode = not maintenance.maintenance_mode
new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
async def show_admins(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Admin.tg_id, Admin.role))
admins = result.all()
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
)
await state.set_state(AdminState.waiting_for_tg_id)
@router.message(AdminState.waiting_for_tg_id)
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
try:
tg_id = int(message.text.strip())
except ValueError:
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if result.scalar_one_or_none():
await message.answer("⚠️ Такой админ уже существует.")
else:
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
await session.commit()
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
role = admin or "moderator"
await callback.message.edit_text(
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
token = Admin.generate_token()
token_hash = hashlib.sha256(token.encode()).hexdigest()
admin.token = token_hash
await session.commit()
msg = await callback.message.edit_text(
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
f"<code>{token}</code>\n\n"
f"⚠️ Это сообщение исчезнет через 5 минут.",
reply_markup=build_token_result_kb(token),
)
await sleep(300)
try:
await msg.delete()
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
tg_id = int(callback_data.action.split("|")[1])
await callback.message.edit_text(
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
try:
_, tg_id_str, role = callback_data.action.split("|")
tg_id = int(tg_id_str)
if role not in ("superadmin", "moderator"):
raise ValueError
except Exception:
await callback.message.edit_text("❌ Неверный формат.")
return
if tg_id == callback.from_user.id:
await callback.message.edit_text(
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
)
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
admin.role = role
await session.commit()
await callback.message.edit_text(
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
await session.commit()
await callback.message.edit_text(
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
async def handle_database_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🗄 <b>Управление базой данных</b>",
reply_markup=build_database_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
"⚠️ Все текущие данные будут перезаписаны.",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(DatabaseState.waiting_for_backup_file)
@router.message(DatabaseState.waiting_for_backup_file)
async def restore_database(message: Message, state: FSMContext, bot: Bot):
document = message.document
if not document or not document.file_name.endswith(".sql"):
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
return
try:
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
tmp_path = tmp_file.name
await bot.download(document, destination=tmp_path)
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
is_custom_dump = False
with open(tmp_path, "rb") as f:
signature = f.read(5)
if signature == b"PGDMP":
is_custom_dump = True
subprocess.run(
[
"sudo",
"-u",
"postgres",
"psql",
"-d",
"postgres",
"-c",
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
check=True,
)
logger.info("[Restore] База данных пересоздана")
os.environ["PGPASSWORD"] = DB_PASSWORD
if is_custom_dump:
result = subprocess.run(
[
"pg_restore",
f"--dbname={DB_NAME}",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"--no-owner",
"--exit-on-error",
tmp_path,
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[
"psql",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-d",
DB_NAME,
"-f",
tmp_path,
],
capture_output=True,
text=True,
)
del os.environ["PGPASSWORD"]
if result.returncode != 0:
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
await message.answer(
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
)
return
await message.answer(
"✅ База данных восстановлена.",
reply_markup=build_back_to_db_menu(),
)
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
await state.clear()
sys.exit(0)
except Exception as e:
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
await message.answer(
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
)
finally:
try:
os.remove(tmp_path)
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
async def handle_export_db(callback: CallbackQuery):
await callback.message.edit_text(
"📤 Выберите панель, с которой требуется получить данные:\n\n"
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
reply_markup=build_export_db_sources_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
async def back_to_database_menu(callback: CallbackQuery):
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
servers = result.scalars().all()
if not servers:
await callback.message.edit_text(
"❌ Нет доступных Remnawave-серверов.",
reply_markup=build_back_to_db_menu(),
)
return
server = servers[0]
api = RemnawaveAPI(base_url=server.api_url)
users = await api.get_all_users_time(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
)
if not users:
await callback.message.edit_text(
"📭 На панели нет клиентов.",
reply_markup=build_back_to_db_menu(),
)
return
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
added_users = await import_remnawave_users(session, users)
server_id = server.cluster_name or server.server_name
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
preview = ""
for i, user in enumerate(users[:3], 1):
email = user.get("email") or user.get("username") or "-"
expire = user.get("expireAt", "")[:10]
preview += f"{i}. {email} — до {expire}\n"
await callback.message.edit_text(
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
f"<b>Первые 3:</b>\n{preview}",
reply_markup=build_back_to_db_menu(),
)
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
if not tg_id:
continue
exists = await session.execute(select(User).where(User.tg_id == tg_id))
if exists.scalar():
continue
try:
new_user = User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
session.add(new_user)
added += 1
except SQLAlchemyError as e:
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
continue
await session.commit()
return added
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
client_id = user.get("uuid")
email = user.get("email") or user.get("username")
remnawave_link = user.get("subscriptionUrl")
expire_at = user.get("expireAt")
created_at = user.get("createdAt")
if not tg_id or not client_id:
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
continue
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
if exists_stmt.scalar():
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
continue
try:
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_ts,
expiry_time=expire_ts,
key="",
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
session.add(new_key)
added += 1
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
except Exception as e:
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
await session.commit()
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
return added
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(Import3xuiStates.waiting_for_file)
@router.message(Import3xuiStates.waiting_for_file, F.document)
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
file = message.document
if not file.file_name.endswith(".db"):
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
return
file_path = f"/tmp/{file.file_name}"
await message.bot.download(file, destination=file_path)
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
try:
from database.importer import import_keys_from_3xui_db
imported, skipped = await import_keys_from_3xui_db(file_path, session)
await processing_message.edit_text(
f"✅ Восстановление завершено:\n"
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
reply_markup=build_post_import_kb(),
)
except Exception as e:
logger.error(f"[Import 3x-ui] Ошибка: {e}")
await processing_message.edit_text(
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
reply_markup=build_back_to_db_menu(),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
await callback.answer("🔁 Начинаю перевыпуск подписок...")
result = await session.execute(select(Key.tg_id, Key.email))
keys = result.all()
success = 0
failed = 0
for tg_id, email in keys:
try:
await update_subscription(tg_id=tg_id, email=email, session=session)
success += 1
except Exception as e:
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
failed += 1
await callback.message.edit_text(
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
reply_markup=build_back_to_db_menu(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📤 <b>Загрузка файла</b>\n\n"
"Вы можете заменить файл в корневой директории бота.\n\n"
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
"как у уже существующего файла. Он будет автоматически заменён.",
reply_markup=build_admin_back_kb("management"),
)
await state.set_state(FileUploadState.waiting_for_file)
@router.message(FileUploadState.waiting_for_file, F.document)
async def handle_admin_file_upload(message: Message, state: FSMContext):
document = message.document
file_name = document.file_name
if not file_name or "." not in file_name:
await message.answer("❌ У файла должно быть имя с расширением.")
return
dest_path = os.path.abspath(f"./{file_name}")
try:
await message.bot.download(document, destination=dest_path)
await message.answer(
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
reply_markup=build_admin_back_kb("management"),
)
except Exception as e:
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
await message.answer(
f"❌ Не удалось сохранить файл: {e}",
reply_markup=build_admin_back_kb("management"),
)
await state.clear()
+11 -1
View File
@@ -29,7 +29,7 @@ def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | N
if page > 1:
nav.append(
InlineKeyboardButton(
text="⬅️ Назад",
text=BACK,
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
)
)
@@ -83,11 +83,21 @@ def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
)
builder.button(
text="⬆️ Загрузить settings.py",
callback_data=AdminPanelCallback(action="module_upload_settings", page=page).pack(),
)
builder.button(
text="⬆️ Загрузить texts.py",
callback_data=AdminPanelCallback(action="module_upload_texts", page=page).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ К списку",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
builder.adjust(1)
return builder.as_markup()
+6 -5
View File
@@ -1,7 +1,5 @@
import os
from math import ceil
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -23,9 +21,12 @@ def list_installed_modules() -> list[tuple[str, str | None]]:
if not os.path.isdir(base):
return []
items: list[tuple[str, str | None]] = []
for name in sorted(os.listdir(base)):
path = os.path.join(base, name)
if os.path.isdir(path) and not name.startswith("."):
for raw_name in sorted(os.listdir(base)):
path = os.path.join(base, raw_name)
if os.path.isdir(path) and not raw_name.startswith("."):
name = (raw_name or "").strip()
if not name:
continue
ver = None
vp = os.path.join(path, "VERSION")
if os.path.isfile(vp):
+89 -32
View File
@@ -21,62 +21,116 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
is_super = admin_role == "superadmin"
is_moderator = admin_role == "moderator"
builder.row(
InlineKeyboardButton(
text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack()
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
),
InlineKeyboardButton(
text="🔑 Поиск подписок",
callback_data=AdminPanelCallback(action="search_key").pack(),
),
InlineKeyboardButton(text="🔑 Поиск подписок", callback_data=AdminPanelCallback(action="search_key").pack()),
)
if admin_role == "superadmin":
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
if is_super:
builder.row(
InlineKeyboardButton(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
)
)
builder.button(
text="💸Управление тарифами",
callback_data=AdminPanelCallback(action="tariffs").pack(),
builder.row(
InlineKeyboardButton(
text="💸Управление тарифами",
callback_data=AdminPanelCallback(action="tariffs").pack(),
)
)
builder.button(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").pack(),
builder.row(
InlineKeyboardButton(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").pack(),
)
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
InlineKeyboardButton(
text="📢 Рассылка",
callback_data=AdminPanelCallback(action="sender").pack(),
),
InlineKeyboardButton(
text="🎟️ Купоны",
callback_data=AdminPanelCallback(action="coupons").pack(),
),
)
if admin_role == "superadmin":
if is_super:
builder.row(
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
InlineKeyboardButton(text="🧩 Мои модули", callback_data=AdminPanelCallback(action="modules").pack()),
InlineKeyboardButton(
text="🎁 Подарки",
callback_data=AdminPanelCallback(action="gifts").pack(),
),
InlineKeyboardButton(
text="🧩 Мои модули",
callback_data=AdminPanelCallback(action="modules").pack(),
),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
InlineKeyboardButton(
text="📊 Статистика",
callback_data=AdminPanelCallback(action="stats").pack(),
),
InlineKeyboardButton(
text="📈 Аналитика",
callback_data=AdminPanelCallback(action="ads").pack(),
),
)
else:
builder.button(
text="🎁 Подарки",
callback_data=AdminPanelCallback(action="gifts").pack(),
builder.row(
InlineKeyboardButton(
text="🎁 Подарки",
callback_data=AdminPanelCallback(action="gifts").pack(),
)
)
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
builder = insert_hook_buttons(builder, module_buttons)
builder.button(
text=MAIN_MENU,
callback_data="profile",
)
if not is_moderator:
builder.row(
InlineKeyboardButton(
text="⚙️ Настройки",
callback_data=AdminPanelCallback(action="settings").pack(),
)
)
if admin_role == "superadmin":
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
else:
builder.adjust(2, 2, 1, 1)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
return builder.as_markup()
markup = builder.as_markup()
if is_super:
ads_callback = AdminPanelCallback(action="ads").pack()
emoji_button = InlineKeyboardButton(
text="😀 Эмоджи",
callback_data=AdminPanelCallback(action="emoji").pack(),
)
inserted = False
for index, row in enumerate(markup.inline_keyboard):
for button in row:
if getattr(button, "callback_data", None) == ads_callback:
markup.inline_keyboard.insert(index + 1, [emoji_button])
inserted = True
break
if inserted:
break
if not inserted:
markup.inline_keyboard.append([emoji_button])
return markup
def build_restart_kb() -> InlineKeyboardMarkup:
@@ -105,4 +159,7 @@ def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
return InlineKeyboardButton(
text=text,
callback_data=AdminPanelCallback(action=action).pack(),
)
+3 -2
View File
@@ -1,3 +1,4 @@
__all__ = ("router",)
from .sender_handler import router
__all__ = ["router"]
+75 -241
View File
@@ -1,230 +1,24 @@
import asyncio
import json
import re
from datetime import datetime
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from database.models import Server
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
from .sender_service import BroadcastService
from .sender_states import AdminSender
from .sender_utils import get_recipients, parse_message_buttons
router = Router()
async def try_add_blocked_user(tg_id: int, session: AsyncSession):
if session:
try:
await create_blocked_user(session, tg_id)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception as e:
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
results = []
min_interval = 1.0 / 15
for msg in messages:
tg_id = msg["tg_id"]
text = msg["text"]
photo = msg.get("photo")
keyboard = msg.get("keyboard")
try:
if photo:
await bot.send_photo(chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
except TelegramRetryAfter as e:
retry_in = int(e.retry_after) + 1
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
await asyncio.sleep(e.retry_after)
try:
if photo:
await bot.send_photo(
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
error_msg = str(bad_request).lower()
if "chat not found" in error_msg:
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session)
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
results.append(False)
except Exception as retry_error:
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
results.append(False)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
error_msg = str(bad_request).lower()
if "chat not found" in error_msg:
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session)
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
results.append(False)
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
results.append(False)
await asyncio.sleep(min_interval)
return results
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str = None) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
)
query = None
if send_to == "subscribed":
query = (
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "hotleads":
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(Payment.amount > 0)
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
query = (
select(distinct(Key.tg_id))
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
else:
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
return tg_ids, len(tg_ids)
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
if "BUTTONS:" not in text:
return text, None
parts = text.split("BUTTONS:", 1)
clean_text = parts[0].strip()
buttons_text = parts[1].strip()
if not buttons_text:
return clean_text, None
buttons = []
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
for line in button_lines:
try:
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
button_data = json.loads(cleaned_line)
if not isinstance(button_data, dict) or "text" not in button_data:
logger.warning(f"Неверный формат кнопки: {line}")
continue
text_btn = button_data["text"]
if "callback" in button_data:
callback_data = button_data["callback"]
if len(callback_data) > 64:
logger.warning(f"Callback слишком длинный: {callback_data}")
continue
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
elif "url" in button_data:
url = button_data["url"]
button = InlineKeyboardButton(text=text_btn, url=url)
else:
logger.warning(f"Кнопка без действия: {line}")
continue
buttons.append([button])
except json.JSONDecodeError as e:
logger.warning(f"Ошибка парсинга JSON кнопки: {line} - {e}")
continue
except Exception as e:
logger.error(f"Ошибка создания кнопки: {line} - {e}")
continue
if not buttons:
return clean_text, None
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
@@ -237,18 +31,30 @@ async def handle_sender(callback_query: CallbackQuery):
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
logger.debug("[Sender] Сообщение не изменено")
else:
raise
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_cluster_select(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
)
@router.callback_query(
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
async def handle_broadcast_type(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
await callback_query.message.edit_text(
text=(
"✍️ Введите текст сообщения для рассылки\n\n"
@@ -272,20 +78,6 @@ async def handle_sender_callback_text(
await state.set_state(AdminSender.waiting_for_message)
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
)
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: AsyncSession):
original_text = message.html_text or message.text or message.caption or ""
@@ -307,6 +99,21 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
cluster_name = data.get("cluster_name")
_, user_count = await get_recipients(session, send_to, cluster_name)
if keyboard:
try:
keyboard_dict = keyboard.model_dump()
InlineKeyboardMarkup.model_validate(keyboard_dict)
except Exception as e:
await message.answer(
f"❌ <b>Ошибка в клавиатуре!</b>\n\n"
f"Не удалось сохранить клавиатуру из указанных кнопок.\n"
f"Ошибка: {str(e)}\n\n"
f"Пожалуйста, проверьте формат кнопок и попробуйте снова.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
@@ -320,16 +127,16 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
InlineKeyboardButton(text="📤 Отправить", callback_data="send_broadcast"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_broadcast"),
]
]
),
)
@router.callback_query(F.data == "send_message", IsAdminFilter())
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
@router.callback_query(F.data == "send_broadcast", IsAdminFilter())
async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
text_message = data.get("text")
photo = data.get("photo")
@@ -342,10 +149,27 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
try:
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
except Exception as e:
logger.error(f"Ошибка восстановления клавиатуры: {e}")
logger.error(f"[Sender] Ошибка восстановления клавиатуры: {e}")
await callback_query.message.edit_text(
f"❌ <b>Ошибка восстановления клавиатуры!</b>\n\n"
f"Не удалось восстановить клавиатуру из сохраненных данных.\n"
f"Ошибка: {str(e)}\n\n"
f"Пожалуйста, создайте рассылку заново.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
if not tg_ids:
await callback_query.message.edit_text(
"⚠️ Не найдено получателей для рассылки.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
messages = []
@@ -353,23 +177,33 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15, session=session)
success_count = sum(1 for result in results if result)
broadcast_service = BroadcastService(bot=callback_query.bot, session=session, messages_per_second=35)
stats = await broadcast_service.broadcast(messages, workers=5)
duration_minutes = int(stats["total_duration"] // 60)
duration_seconds = int(stats["total_duration"] % 60)
duration_str = (
f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
)
await callback_query.message.answer(
text=(
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f"✅ <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
f"✅ <b>Доставлено:</b> {stats['success_count']}\n"
f"❌ <b>Не доставлено:</b> {stats['failed_count']}\n"
f"🚫 <b>Заблокировавших бота:</b> {stats['blocked_users']}\n\n"
f"⏱️ <b>Время выполнения:</b> {duration_str}\n"
f"⚡ <b>Средняя скорость:</b> {stats['avg_speed']:.1f} сообщений/сек"
),
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "cancel_broadcast", IsAdminFilter())
async def handle_broadcast_cancel(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
+230
View File
@@ -0,0 +1,230 @@
import asyncio
import time
from collections import deque
from typing import Any
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy.ext.asyncio import AsyncSession
from logger import logger
class BroadcastMessage:
def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None) -> None:
self.tg_id = tg_id
self.text = text
self.photo = photo
self.keyboard = keyboard
self.retry_after = None
self.attempts = 0
class RateLimiter:
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
self.max_rate = max_rate
self.window = window
self.send_times = deque()
self.lock = asyncio.Lock()
def _clean_old_timestamps(self, current_time: float):
cutoff_time = current_time - self.window
while self.send_times and self.send_times[0] <= cutoff_time:
self.send_times.popleft()
async def acquire(self):
async with self.lock:
while True:
now = time.time()
self._clean_old_timestamps(now)
if len(self.send_times) < self.max_rate:
self.send_times.append(now)
return
oldest_timestamp = self.send_times[0]
time_to_wait = (oldest_timestamp + self.window) - now
if time_to_wait > 0:
await asyncio.sleep(time_to_wait + 0.001)
class BroadcastService:
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
self.bot = bot
self.session = session
self.rate_limiter = RateLimiter(max_rate=messages_per_second)
self.blocked_users = set()
self.queue = asyncio.Queue()
self.delayed_queue = asyncio.Queue()
self.results = []
self.total_sent = 0
self.start_time = None
self.is_running = False
async def _send_single_message(self, msg: BroadcastMessage) -> bool:
try:
await self.rate_limiter.acquire()
if msg.photo:
await self.bot.send_photo(
chat_id=msg.tg_id, photo=msg.photo, caption=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
)
else:
await self.bot.send_message(
chat_id=msg.tg_id, text=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
)
return True
except TelegramRetryAfter as e:
msg.retry_after = e.retry_after
msg.attempts += 1
logger.warning(
f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. (попытка {msg.attempts})"
)
await self.delayed_queue.put(msg)
return False
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {msg.tg_id}")
self.blocked_users.add(msg.tg_id)
return False
except TelegramBadRequest as e:
error_msg = str(e).lower()
if "chat not found" in error_msg:
logger.warning(f"🚫 Чат не найден для пользователя {msg.tg_id}")
self.blocked_users.add(msg.tg_id)
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {msg.tg_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения пользователю {msg.tg_id}: {e}")
return False
async def _process_delayed_messages(self):
while self.is_running:
try:
if not self.delayed_queue.empty():
msg = await asyncio.wait_for(self.delayed_queue.get(), timeout=0.1)
if msg.retry_after:
await asyncio.sleep(msg.retry_after)
msg.retry_after = None
if msg.attempts < 3:
await self.queue.put(msg)
else:
logger.error(f"❌ Достигнут лимит попыток для {msg.tg_id}")
self.results.append(False)
else:
await asyncio.sleep(0.1)
except TimeoutError:
continue
except Exception as e:
logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}")
await asyncio.sleep(0.1)
async def _worker(self):
while self.is_running:
try:
msg = await asyncio.wait_for(self.queue.get(), timeout=0.1)
success = await self._send_single_message(msg)
if success:
self.total_sent += 1
self.results.append(True)
elif msg.attempts == 0:
self.results.append(False)
self.queue.task_done()
except TimeoutError:
continue
except Exception as e:
logger.error(f"❌ Ошибка в воркере рассылки: {e}")
await asyncio.sleep(0.1)
async def _save_blocked_users(self):
if not self.blocked_users:
return
try:
from sqlalchemy.dialects.postgresql import insert
from database.models import BlockedUser
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await self.session.execute(stmt)
await self.session.commit()
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
except Exception as e:
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
await self.session.rollback()
async def broadcast(self, messages: list[dict], workers: int = 20) -> dict:
self.is_running = True
self.start_time = time.time()
self.results = []
self.total_sent = 0
self.blocked_users = set()
for msg_data in messages:
msg = BroadcastMessage(
tg_id=msg_data["tg_id"],
text=msg_data["text"],
photo=msg_data.get("photo"),
keyboard=msg_data.get("keyboard"),
)
await self.queue.put(msg)
logger.info(f"📤 Начата рассылка на {len(messages)} пользователей с {workers} воркерами")
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(workers)]
delayed_task = asyncio.create_task(self._process_delayed_messages())
await self.queue.join()
await asyncio.sleep(1)
while not self.delayed_queue.empty():
await asyncio.sleep(1)
self.is_running = False
for task in worker_tasks:
task.cancel()
delayed_task.cancel()
await asyncio.gather(*worker_tasks, delayed_task, return_exceptions=True)
await self._save_blocked_users()
end_time = time.time()
total_duration = end_time - self.start_time
success_count = sum(1 for r in self.results if r)
avg_speed = self.total_sent / total_duration if total_duration > 0 else 0
stats = {
"total_duration": total_duration,
"total_sent": self.total_sent,
"success_count": success_count,
"failed_count": len(self.results) - success_count,
"avg_speed": avg_speed,
"total_messages": len(messages),
"blocked_users": len(self.blocked_users),
}
logger.info(
f"✅ Рассылка завершена: {success_count}/{len(messages)} успешно, "
f"скорость: {avg_speed:.1f} сообщений/сек, время: {total_duration:.1f} сек"
)
return stats
+6
View File
@@ -0,0 +1,6 @@
from aiogram.fsm.state import State, StatesGroup
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
+146
View File
@@ -0,0 +1,146 @@
import json
import re
from datetime import datetime
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from logger import logger
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
)
query = None
if send_to == "subscribed":
query = (
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "hotleads":
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(Payment.amount > 0)
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
query = (
select(distinct(Key.tg_id))
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
else:
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
return tg_ids, len(tg_ids)
def strip_html_tags(text: str) -> str:
text = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", text)
text = re.sub(r"<[^>]+>", "", text)
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
return text.strip()
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
if "BUTTONS:" not in text:
return text, None
parts = text.split("BUTTONS:", 1)
clean_text = parts[0].strip()
buttons_text = parts[1].strip()
if not buttons_text:
return clean_text, None
buttons = []
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
for line in button_lines:
try:
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
button_data = json.loads(cleaned_line)
if not isinstance(button_data, dict) or "text" not in button_data:
logger.warning(f"[Sender] Неверный формат кнопки: {line}")
continue
text_btn = button_data["text"]
if "callback" in button_data:
callback_data = button_data["callback"]
if len(callback_data) > 64:
logger.warning(f"[Sender] Callback слишком длинный: {callback_data}")
continue
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
elif "url" in button_data:
url = button_data["url"]
button = InlineKeyboardButton(text=text_btn, url=url)
else:
logger.warning(f"[Sender] Кнопка без действия: {line}")
continue
buttons.append([button])
except json.JSONDecodeError as e:
logger.warning(f"[Sender] Ошибка парсинга JSON кнопки: {line} - {e}")
continue
except Exception as e:
logger.error(f"[Sender] Ошибка создания кнопки: {line} - {e}")
continue
if not buttons:
return clean_text, None
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard
+5 -3
View File
@@ -1,3 +1,5 @@
__all__ = ("router",)
from .servers_handler import router
from . import (
server_manage, # noqa: F401
server_wizard, # noqa: F401
)
from .server_states import router
+4 -6
View File
@@ -37,7 +37,7 @@ def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -
)
builder.button(
text="🔙 Назад",
text=BACK,
callback_data=f"cluster_servers|{cluster_name}",
)
@@ -70,9 +70,7 @@ def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKe
builder.row(InlineKeyboardButton(text="⚙️ Тип панели", callback_data=f"select_panel_type|{server_name}"))
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
InlineKeyboardButton(text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack())
)
return builder.as_markup()
@@ -83,7 +81,7 @@ def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
inline_keyboard=[
[InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
[InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}")],
[InlineKeyboardButton(text=BACK, callback_data=f"edit_server|{server_name}")],
]
)
@@ -94,7 +92,7 @@ def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineK
for cluster in clusters:
builder.row(InlineKeyboardButton(text=cluster, callback_data=f"set_cluster|{server_name}|{cluster}"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"edit_server|{server_name}"))
return builder.as_markup()
@@ -1,44 +1,18 @@
from aiogram import F, Router, types
from aiogram import F, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select, update
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Key, Server
from database.servers import (
get_available_clusters,
get_server_by_name,
update_server_cluster,
update_server_field,
update_server_name_with_keys,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_cancel_edit_kb,
build_cluster_selection_kb,
build_edit_server_fields_kb,
build_manage_server_kb,
build_panel_type_selection_kb,
)
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
class ServerEditState(StatesGroup):
choosing_field = State()
editing_value = State()
from .keyboard import AdminServerCallback, build_manage_server_kb
from .server_states import ServerLimitState, router
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
@@ -102,11 +76,19 @@ async def process_callback_delete_server(
state: FSMContext,
session: AsyncSession,
):
from ..clusters.clusters_handler import AdminClusterStates
from sqlalchemy import delete as sa_delete
from database import get_servers as get_servers_inner
from database.models import (
Key as KeyModel,
Server as ServerModel,
)
from ..clusters.base import AdminClusterStates
server_name = callback_data.data
servers_dict = await get_servers(session, include_enabled=True)
servers_dict = await get_servers_inner(session, include_enabled=True)
cluster_name = None
for c_name, server_list in servers_dict.items():
if any(s["server_name"] == server_name for s in server_list):
@@ -120,16 +102,23 @@ async def process_callback_delete_server(
)
return
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
stmt_keys_count = select(func.count()).where(KeyModel.server_id == server_name)
result = await session.execute(stmt_keys_count)
keys_count = result.scalar_one()
if keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
subq = select(func.count()).where(Key.server_id == Server.server_name).correlate(Server).scalar_subquery()
subq = (
select(func.count())
.where(KeyModel.server_id == ServerModel.server_name)
.correlate(ServerModel)
.scalar_subquery()
)
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(Server.server_name != server_name)
stmt_all_servers = select(ServerModel.server_name, subq.label("key_count")).where(
ServerModel.server_name != server_name
)
result = await session.execute(stmt_all_servers)
all_servers = result.all()
@@ -139,20 +128,22 @@ async def process_callback_delete_server(
callback_data = f"transfer_to_server|{s_name}|{server_name}"
if len(callback_data.encode("utf-8")) > 64:
await callback_query.message.edit_text(
text=f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
text=(
f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
),
reply_markup=build_admin_back_kb("clusters"),
)
return
builder.row(
InlineKeyboardButton(
types.InlineKeyboardButton(
text=f"{s_name} ({key_count})",
callback_data=callback_data,
)
)
builder.row(
InlineKeyboardButton(
types.InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
@@ -182,6 +173,8 @@ async def process_callback_delete_server(
cluster_keys_count = result.scalar_one()
if cluster_keys_count > 0:
from ..clusters.base import AdminClusterStates
await state.update_data(server_name=server_name, cluster_name=cluster_name)
subq_cluster = (
@@ -201,41 +194,53 @@ async def process_callback_delete_server(
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
if len(callback_data.encode("utf-8")) > 64:
await callback_query.message.edit_text(
text=f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
text=(
f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова."
),
reply_markup=build_admin_back_kb("clusters"),
)
return
builder.row(
InlineKeyboardButton(
types.InlineKeyboardButton(
text=f"{cl_name} ({key_count})",
callback_data=callback_data,
)
)
builder.row(
InlineKeyboardButton(
types.InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
)
await callback_query.message.edit_text(
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
text=(
f"⚠️ Это последний сервер в кластере '{cluster_name}'. "
f"На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:"
),
reply_markup=builder.as_markup(),
)
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
return
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
stmt_delete = sa_delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
text=(
f"✅ Сервер '{server_name}' удален. "
f"Кластер '{cluster_name}' также удален, так как в нем не осталось серверов."
),
reply_markup=build_admin_back_kb("clusters"),
)
else:
stmt_delete = delete(Server).where((Server.cluster_name == cluster_name) & (Server.server_name == server_name))
stmt_delete = sa_delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
await callback_query.message.edit_text(
@@ -341,140 +346,3 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
except ValueError:
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
server_name = callback.data.split("|")[1]
await state.clear()
server_data = await get_server_by_name(session, server_name)
if not server_data:
await callback.message.edit_text("❌ Сервер не найден.")
return
await callback.message.edit_text(
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
reply_markup=build_edit_server_fields_kb(server_name, server_data),
)
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, server_name, field = callback.data.split("|")
if field == "cluster_name":
clusters = await get_available_clusters(session)
await callback.message.edit_text(
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
reply_markup=build_cluster_selection_kb(server_name, clusters),
)
return
await state.update_data(server_name=server_name, field=field)
await state.set_state(ServerEditState.editing_value)
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID/Squads",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_edit_kb(server_name),
)
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
async def select_panel_type(callback: CallbackQuery):
server_name = callback.data.split("|")[1]
await callback.message.edit_text(
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
reply_markup=build_panel_type_selection_kb(server_name),
)
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
_, server_name, panel_type = callback.data.split("|")
success = await update_server_field(session, server_name, "panel_type", panel_type)
if success:
await callback.message.edit_text(
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
_, server_name, new_cluster = callback.data.split("|")
success = await update_server_cluster(session, server_name, new_cluster)
if success:
await callback.message.edit_text(
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении кластера")
@router.message(ServerEditState.editing_value, IsAdminFilter())
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
server_name = data["server_name"]
field = data["field"]
value = message.text.strip()
if field == "server_name":
if len(value) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
success = await update_server_name_with_keys(session, server_name, value)
if success:
server_name = value
else:
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
return
else:
success = await update_server_field(session, server_name, field, value)
if success:
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID/Squads",
}
await message.answer(
f"{field_names.get(field, field).capitalize()} изменено",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await message.answer("❌ Ошибка при изменении поля")
await state.clear()
+14
View File
@@ -0,0 +1,14 @@
from aiogram import Router
from aiogram.fsm.state import State, StatesGroup
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
class ServerEditState(StatesGroup):
choosing_field = State()
editing_value = State()
+164
View File
@@ -0,0 +1,164 @@
from aiogram import F, types
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from database.servers import (
get_available_clusters,
get_server_by_name,
update_server_cluster,
update_server_field,
update_server_name_with_keys,
)
from filters.admin import IsAdminFilter
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_cancel_edit_kb,
build_cluster_selection_kb,
build_edit_server_fields_kb,
build_panel_type_selection_kb,
)
from .server_states import ServerEditState, router
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
server_name = callback.data.split("|")[1]
await state.clear()
server_data = await get_server_by_name(session, server_name)
if not server_data:
await callback.message.edit_text("❌ Сервер не найден.")
return
await callback.message.edit_text(
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
reply_markup=build_edit_server_fields_kb(server_name, server_data),
)
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, server_name, field = callback.data.split("|")
if field == "cluster_name":
clusters = await get_available_clusters(session)
await callback.message.edit_text(
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
reply_markup=build_cluster_selection_kb(server_name, clusters),
)
return
await state.update_data(server_name=server_name, field=field)
await state.set_state(ServerEditState.editing_value)
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID/Squads",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_edit_kb(server_name),
)
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
async def select_panel_type(callback: CallbackQuery):
server_name = callback.data.split("|")[1]
await callback.message.edit_text(
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
reply_markup=build_panel_type_selection_kb(server_name),
)
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
_, server_name, panel_type = callback.data.split("|")
success = await update_server_field(session, server_name, "panel_type", panel_type)
if success:
await callback.message.edit_text(
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу",
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
_, server_name, new_cluster = callback.data.split("|")
success = await update_server_cluster(session, server_name, new_cluster)
if success:
await callback.message.edit_text(
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу",
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении кластера")
@router.message(ServerEditState.editing_value, IsAdminFilter())
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
server_name = data["server_name"]
field = data["field"]
value = message.text.strip()
if field == "server_name":
if len(value) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
success = await update_server_name_with_keys(session, server_name, value)
if success:
server_name = value
else:
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
return
else:
success = await update_server_field(session, server_name, field, value)
if success:
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID/Squads",
}
await message.answer(
f"{field_names.get(field, field).capitalize()} изменено",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу",
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
.as_markup(),
)
else:
await message.answer("❌ Ошибка при изменении поля")
await state.clear()
+23
View File
@@ -0,0 +1,23 @@
from aiogram import Router
from filters.admin import IsSuperAdminFilter
from .settings_buttons import router as settings_buttons_router
from .settings_cashboxes import router as settings_cashboxes_router
from .settings_manage import router as settings_manage_router
from .settings_modes import router as settings_modes_router
from .settings_money import router as settings_panels_router
from .settings_notifications import router as settings_notifications_router
from .settings_tariffs import router as settings_tariffs_router
router = Router(name="admin_settings")
router.callback_query.filter(IsSuperAdminFilter())
router.message.filter(IsSuperAdminFilter())
router.include_router(settings_manage_router)
router.include_router(settings_buttons_router)
router.include_router(settings_cashboxes_router)
router.include_router(settings_panels_router)
router.include_router(settings_notifications_router)
router.include_router(settings_modes_router)
router.include_router(settings_tariffs_router)
+272
View File
@@ -0,0 +1,272 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from core.settings.money_config import get_currency_mode
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from .settings_config import (
BUTTON_TITLES,
MODES_TITLES,
MONEY_FIELDS,
NOTIFICATION_TIME_FIELDS,
NOTIFICATION_TITLES,
PAYMENT_PROVIDER_TITLES,
)
def build_toggle_section_keyboard(
titles: dict[str, str],
state: dict[str, bool],
action: str,
columns: int,
back_action: str = "settings",
extra_rows: list[list[InlineKeyboardButton]] | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for index, key in enumerate(titles.keys(), start=1):
title = titles[key]
current_state = bool(state.get(key, False))
prefix = "" if current_state else ""
builder.button(
text=f"{prefix} {title}",
callback_data=AdminPanelCallback(
action=action,
page=index,
).pack(),
)
builder.adjust(columns)
if extra_rows:
for row in extra_rows:
builder.row(*row)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action=back_action).pack(),
)
)
return builder.as_markup()
def build_settings_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="Кассы",
callback_data=AdminPanelCallback(action="settings_cashboxes").pack(),
)
builder.button(
text="Деньги",
callback_data=AdminPanelCallback(action="settings_money").pack(),
)
builder.button(
text="Кнопки",
callback_data=AdminPanelCallback(action="settings_buttons").pack(),
)
builder.button(
text="Уведомления",
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
)
builder.button(
text="Режимы",
callback_data=AdminPanelCallback(action="settings_modes").pack(),
)
builder.button(
text="Тарификация",
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
)
builder.adjust(2, 2, 2)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_settings_buttons_kb(buttons_state: dict[str, bool]) -> InlineKeyboardMarkup:
return build_toggle_section_keyboard(
titles=BUTTON_TITLES,
state=buttons_state,
action="settings_button_toggle",
columns=2,
back_action="settings",
)
def build_settings_cashboxes_kb(providers_state: dict[str, bool]) -> InlineKeyboardMarkup:
order_button = InlineKeyboardButton(
text="📋 Порядок касс",
callback_data=AdminPanelCallback(action="settings_providers_order").pack(),
)
return build_toggle_section_keyboard(
titles=PAYMENT_PROVIDER_TITLES,
state=providers_state,
action="settings_cashbox_toggle",
columns=2,
back_action="settings",
extra_rows=[[order_button]],
)
def build_providers_order_kb(sorted_names: list[str]) -> InlineKeyboardMarkup:
"""Клавиатура для управления порядком отображения касс."""
builder = InlineKeyboardBuilder()
for idx, name in enumerate(sorted_names):
title = PAYMENT_PROVIDER_TITLES.get(name, name)
pos = idx + 1
builder.row(
InlineKeyboardButton(
text="⬆️",
callback_data=AdminPanelCallback(
action="settings_order_up",
page=pos,
).pack(),
),
InlineKeyboardButton(
text=f"{pos}. {title}",
callback_data=AdminPanelCallback(
action="settings_providers_order",
).pack(),
),
InlineKeyboardButton(
text="⬇️",
callback_data=AdminPanelCallback(
action="settings_order_down",
page=pos,
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🔄 Сбросить порядок",
callback_data=AdminPanelCallback(action="settings_order_reset").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="settings_cashboxes").pack(),
)
)
return builder.as_markup()
def build_settings_notifications_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
intervals_button = InlineKeyboardButton(
text="Интервалы",
callback_data=AdminPanelCallback(action="settings_notifications_intervals").pack(),
)
return build_toggle_section_keyboard(
titles=NOTIFICATION_TITLES,
state={k: bool(notifications_state.get(k, False)) for k in NOTIFICATION_TITLES},
action="settings_notification_toggle",
columns=1,
back_action="settings",
extra_rows=[[intervals_button]],
)
def build_settings_notifications_intervals_kb(notifications_state: dict[str, object]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
keys = list(NOTIFICATION_TIME_FIELDS.keys())
for index, key in enumerate(keys, start=1):
title = NOTIFICATION_TIME_FIELDS[key]
value = notifications_state.get(key)
value_text = "не задано" if value is None else str(value)
builder.button(
text=f"{title}: {value_text}",
callback_data=AdminPanelCallback(
action="settings_notification_interval_edit",
page=index,
).pack(),
)
builder.adjust(1)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="settings_notifications").pack(),
)
)
return builder.as_markup()
def build_settings_modes_kb(modes_state: dict[str, bool]) -> InlineKeyboardMarkup:
return build_toggle_section_keyboard(
titles=MODES_TITLES,
state=modes_state,
action="settings_modes_toggle",
columns=2,
back_action="settings",
)
def build_settings_money_kb(money_state: dict[str, object]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
field_keys = list(MONEY_FIELDS.keys())
for index, key in enumerate(field_keys, start=1):
title = MONEY_FIELDS[key]
value = money_state.get(key)
if key == "RUB_TO_USD":
if value is False or value is None:
value_text = "по ЦБ РФ"
else:
value_text = str(value)
elif key == "CASHBACK":
try:
numeric_value = float(value) if value not in (None, False) else 0.0
except (TypeError, ValueError):
numeric_value = 0.0
if numeric_value <= 0:
value_text = "выкл"
else:
value_text = f"{numeric_value:g} %"
else:
value_text = "не задано" if value is None else str(value)
builder.button(
text=f"{title}: {value_text}",
callback_data=AdminPanelCallback(
action="settings_money_edit",
page=index,
).pack(),
)
mode, one_screen = get_currency_mode()
if mode == "RUB+USD" and one_screen:
mode_text = "RUB+USD (одним экраном)"
else:
mode_text = mode
builder.button(
text=f"Режим валют: {mode_text}",
callback_data=AdminPanelCallback(
action="settings_money_currency",
page=0,
).pack(),
)
builder.adjust(1)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="settings").pack(),
)
)
return builder.as_markup()
@@ -0,0 +1,53 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from core.bootstrap import BUTTONS_CONFIG, update_buttons_config
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
from .keyboard import BUTTON_TITLES, build_settings_buttons_kb
router = Router(name="admin_settings_buttons")
router.callback_query.filter(IsAdminFilter())
async def load_button_settings() -> dict[str, bool]:
config = BUTTONS_CONFIG or {}
return {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_buttons"))
async def open_settings_buttons_menu(callback: CallbackQuery, session: AsyncSession) -> None:
buttons_state = await load_button_settings()
text = "Здесь вы можете включать или отключать кнопки в меню бота."
await callback.message.edit_text(text=text, reply_markup=build_settings_buttons_kb(buttons_state))
await callback.answer()
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_button_toggle"))
async def toggle_button_setting(
callback: CallbackQuery,
callback_data: AdminPanelCallback,
session: AsyncSession,
) -> None:
keys = list(BUTTON_TITLES.keys())
idx = callback_data.page
if not 1 <= idx <= len(keys):
await callback.answer("Неизвестная настройка", show_alert=True)
return
key = keys[idx - 1]
config = dict(BUTTONS_CONFIG or {})
current = bool(config.get(key, False))
config[key] = not current
await update_buttons_config(session, config)
await session.commit()
buttons_state = {k: bool(config.get(k, False)) for k in BUTTON_TITLES.keys()}
await callback.message.edit_reply_markup(reply_markup=build_settings_buttons_kb(buttons_state))
await callback.answer("Настройка обновлена")
@@ -0,0 +1,152 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from core.bootstrap import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from filters.admin import IsAdminFilter
from handlers.payments.providers import PROVIDERS_BASE, _get_effective_order
from ..panel.keyboard import AdminPanelCallback
from .keyboard import PAYMENT_PROVIDER_TITLES, build_providers_order_kb, build_settings_cashboxes_kb
router = Router(name="admin_settings_cashboxes")
router.callback_query.filter(IsAdminFilter())
async def load_payment_providers_settings() -> dict[str, bool]:
config = PAYMENTS_CONFIG or {}
return {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
def _get_sorted_provider_names() -> list[str]:
"""Возвращает все провайдеры, отсортированные по текущему порядку."""
all_names = list(PROVIDERS_BASE.keys())
return sorted(
all_names,
key=lambda n: _get_effective_order(n, PROVIDERS_BASE.get(n, {})),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashboxes"))
async def open_settings_cashboxes_menu(callback: CallbackQuery, session: AsyncSession) -> None:
providers_state = await load_payment_providers_settings()
text = "Здесь можно включать и отключать платёжные провайдеры."
await callback.message.edit_text(text=text, reply_markup=build_settings_cashboxes_kb(providers_state))
await callback.answer()
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_cashbox_toggle"))
async def toggle_cashbox_setting(
callback: CallbackQuery,
callback_data: AdminPanelCallback,
session: AsyncSession,
) -> None:
keys = list(PAYMENT_PROVIDER_TITLES.keys())
index = callback_data.page
if not 1 <= index <= len(keys):
await callback.answer("Неизвестная касса", show_alert=True)
return
provider_code = keys[index - 1]
config = dict(PAYMENTS_CONFIG or {})
current_value = bool(config.get(provider_code, False))
config[provider_code] = not current_value
await update_payments_config(
session,
config,
)
await session.commit()
updated_state = {k: bool(config.get(k, False)) for k in PAYMENT_PROVIDER_TITLES.keys()}
await callback.message.edit_reply_markup(
reply_markup=build_settings_cashboxes_kb(updated_state),
)
await callback.answer("Настройка обновлена")
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_providers_order"))
async def open_providers_order_menu(callback: CallbackQuery, session: AsyncSession) -> None:
sorted_names = _get_sorted_provider_names()
text = (
"📋 <b>Порядок отображения касс</b>\n\n"
"⬆️ — поднять выше\n"
"⬇️ — опустить ниже\n\n"
"Порядок влияет на меню оплаты и fast flow."
)
await callback.message.edit_text(
text=text,
reply_markup=build_providers_order_kb(sorted_names),
)
await callback.answer()
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_up"))
async def move_provider_up(
callback: CallbackQuery,
callback_data: AdminPanelCallback,
session: AsyncSession,
) -> None:
sorted_names = _get_sorted_provider_names()
idx = callback_data.page - 1
if idx <= 0:
await callback.answer("Уже на первом месте", show_alert=False)
return
if idx >= len(sorted_names):
await callback.answer("Неизвестная касса", show_alert=True)
return
sorted_names[idx], sorted_names[idx - 1] = sorted_names[idx - 1], sorted_names[idx]
new_order = {name: (i + 1) * 10 for i, name in enumerate(sorted_names)}
await update_providers_order(session, new_order)
await callback.message.edit_reply_markup(
reply_markup=build_providers_order_kb(sorted_names),
)
await callback.answer("✅ Перемещено выше")
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_down"))
async def move_provider_down(
callback: CallbackQuery,
callback_data: AdminPanelCallback,
session: AsyncSession,
) -> None:
sorted_names = _get_sorted_provider_names()
idx = callback_data.page - 1
if idx >= len(sorted_names) - 1:
await callback.answer("Уже на последнем месте", show_alert=False)
return
if idx < 0:
await callback.answer("Неизвестная касса", show_alert=True)
return
sorted_names[idx], sorted_names[idx + 1] = sorted_names[idx + 1], sorted_names[idx]
new_order = {name: (i + 1) * 10 for i, name in enumerate(sorted_names)}
await update_providers_order(session, new_order)
await callback.message.edit_reply_markup(
reply_markup=build_providers_order_kb(sorted_names),
)
await callback.answer("✅ Перемещено ниже")
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_order_reset"))
async def reset_providers_order(callback: CallbackQuery, session: AsyncSession) -> None:
await update_providers_order(session, {})
sorted_names = _get_sorted_provider_names()
await callback.message.edit_reply_markup(
reply_markup=build_providers_order_kb(sorted_names),
)
await callback.answer("✅ Порядок сброшен на дефолтный")
@@ -0,0 +1,74 @@
from typing import Final
BUTTON_TITLES: Final[dict[str, str]] = {
"CHANNEL_BUTTON_ENABLE": "Канал",
"DONATIONS_BUTTON_ENABLE": "Донаты",
"BALANCE_BUTTON_ENABLE": "Баланс",
"REFERRAL_QR_BUTTON_ENABLE": "QR реф.меню",
"DELETE_KEY_BUTTON_ENABLE": "Удалить подп-ку",
"INSTRUCTIONS_BUTTON_ENABLE": "Инструкции",
"TOGGLE_CLIENT_BUTTON_ENABLE": "Заморозка подписки",
"GIFT_BUTTON_ENABLE": "Подарки",
"REFERRAL_BUTTON_ENABLE": "Реф.система",
"TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов",
"QRCODE_BUTTON_ENABLE": "QR подписки",
"HWID_RESET_BUTTON_ENABLE": "Сброс HWID",
"ANDROID_TV_BUTTON_ENABLE": "Android TV",
"COUPON_BUTTON_ENABLE": "Активировать купон",
}
NOTIFICATION_TITLES: Final[dict[str, str]] = {
"RENEW_ENABLED": "Авто-продление",
"EXPIRY_24H_ENABLED": "За 24 часа",
"EXPIRY_10H_ENABLED": "За 10 часов",
"DELETE_KEY_ENABLED": "Удалять просроченные",
"RENEW_EXPIRED_ENABLED": "Продлевать просроченные",
"HOT_LEADS_ENABLED": "Горячие лиды",
}
NOTIFICATION_TIME_FIELDS: Final[dict[str, str]] = {
"BASE_NOTIFICATION_MINUTE": "Проверка (сек)",
"INACTIVE_USER_ENABLED": "Неактивные (ч)",
"EXPIRY_24H_BEFORE_HOURS": "До 24ч (ч)",
"EXPIRY_10H_BEFORE_HOURS": "До 10ч (ч)",
"DELETE_KEY_DELAY_MINUTES": "Удаление (мин)",
"EXTRA_DAYS_AFTER_EXPIRY": "Дни к пробнику",
"INACTIVE_TRAFFIC_ENABLED": "Трафик неакт. (ч)",
"HOT_LEADS_INTERVAL_HOURS": "Гор.лиды (ч)",
"DISCOUNT_ACTIVE_HOURS": "Скидка (ч)",
}
PAYMENT_PROVIDER_TITLES: Final[dict[str, str]] = {
"YOOKASSA": "YooKassa",
"YOOMONEY": "YooMoney",
"ROBOKASSA": "Robokassa",
"KASSAI_CARDS": "KassaAI карты",
"KASSAI_SBP": "KassaAI СБП",
"TRIBUTE": "Tribute",
"HELEKET": "Heleket",
"CRYPTOBOT": "CryptoBot",
"FREEKASSA": "FreeKassa",
"STARS": "Telegram Stars",
}
MODES_TITLES: Final[dict[str, str]] = {
"CAPTCHA_ENABLED": "Капча",
"CHANNEL_CHECK_ENABLED": "Обязат. канал",
"SHOW_START_MENU_ONLY_ONCE": "Старт один раз",
"INLINE_MODE_ENABLED": "Инлайн-режим",
"RANDOM_SUBSCRIPTIONS_ENABLED": "Случайные страны",
"COUNTRY_SELECTION_ENABLED": "Режим стран",
"REMNAWAVE_WEBAPP_ENABLED": "Remna WebApp",
"REMNAWAVE_WEBAPP_OPEN_IN_BROWSER": "WebApp в браузере",
"HAPP_CRYPTOLINK_ENABLED": "Happ-ссылки",
"LEGACY_LINKS_ENABLED": "Старые ссылки",
"DIRECT_START_DISABLED": "Тихий режим",
"TRIAL_TIME_DISABLED": "Отключить триал",
}
MONEY_FIELDS: Final[dict[str, str]] = {
"FX_MARKUP": "Наценка FX (%)",
"RUB_TO_USD": "Курс USD/RUB",
"CASHBACK": "Кэшбэк (%)",
}
@@ -0,0 +1,27 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_settings_kb
router = Router(name="admin_settings_manage")
router.callback_query.filter(IsAdminFilter())
@router.callback_query(AdminPanelCallback.filter(F.action == "settings"))
async def open_settings_menu(callback: CallbackQuery) -> None:
text = (
"Здесь вы можете изменить основные настройки бота, не перезагружая его\n"
"(Меню будет пополняться)\n\n"
"<blockquote>"
"⚠️⚠️⚠️ ВАЖНО! Эти настройки являются техническими и не рассчитаны на обычное использование.\n"
"Не включайте и не меняйте настройки, если вы не понимаете, что они делают!\n"
"Бездумные изменения могут нарушить работу бота или базы данных."
"</blockquote>\n\n"
"Если вы не уверены, что делает настройка — уточните вопрос в чате."
)
await callback.message.edit_text(text=text, reply_markup=build_settings_kb())
await callback.answer()

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