Compare commits

...

31 Commits

Author SHA1 Message Date
Egor f193b0bfcf Update Python version argument to v2.9.1 2025-12-12 06:29:18 +03:00
Egor b961529001 Update version to v2.9.1 in docker-registry.yml 2025-12-12 06:29:09 +03:00
Egor afa172f3cc Update docker-hub.yml 2025-12-12 06:28:52 +03:00
Egor 8be1d7e423 Delete app/handlers/.DS_Store 2025-12-12 06:10:15 +03:00
Egor 8c4db0a0e6 Delete app/.DS_Store 2025-12-12 06:09:46 +03:00
Egor a8da98c35e Update docker-compose.yml 2025-12-12 06:07:54 +03:00
Egor 7ca6942443 Rename vpn_logo (1).png to vpn_logo.png 2025-12-12 06:07:06 +03:00
Egor 3f97d22a76 Add files via upload 2025-12-12 06:06:42 +03:00
Egor e3dccf2c1b Merge pull request #2144 from Gy9vin/main
Обновки
2025-12-12 06:04:37 +03:00
Egor d0e25fff3d Merge pull request #2145 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:03:41 +03:00
Egor 3699d248b1 Add enrich_happ_links parameter to get_all_users 2025-12-12 05:59:58 +03:00
Egor bffbf3ea39 Update remnawave_api.py 2025-12-12 05:59:01 +03:00
gy9vin e869c9b167 Обновление env 2025-12-11 23:21:12 +03:00
gy9vin 0b2b109e86 Минификсы 2025-12-11 23:11:23 +03:00
gy9vin 5dd586e0b2 Новый фильтр Готовы к продлению 2025-12-11 22:42:37 +03:00
gy9vin c9de084efa Фикс корзины 2025-12-11 22:25:42 +03:00
gy9vin 81b3c7ed3f черный список + фиксы 2025-12-11 10:56:14 +03:00
gy9vin 80785f22b0 Черный список, мониторинг суточно графика по регламенту 2025-12-10 19:13:52 +03:00
Egor b1cb368bf6 Merge pull request #2139 from BEDOLAGA-DEV/main
w
2025-12-10 02:21:37 +03:00
Egor 9fc977ace3 Merge pull request #2132 from remnawave-contrib/ivan-nginx-platega-min
Reduce minimum amount for Platega transactions from 10000 to 100 kope…
2025-12-10 02:16:49 +03:00
Egor 47acd37449 Merge pull request #2133 from remnawave-contrib/ivan-nginx-health-check
fix: healthcheck command to include API key in requests
2025-12-10 02:16:08 +03:00
Egor d7fa956f58 Merge pull request #2138 from BEDOLAGA-DEV/dev5
Dev5
2025-12-10 02:14:54 +03:00
Egor 98164c2083 Merge pull request #2137 from BEDOLAGA-DEV/g0wcwm-bedolaga/fix-api-issue-with-faq-status-editing
Fix FAQ status route matching
2025-12-10 02:10:00 +03:00
Egor 81f5ce429e Fix FAQ status route matching 2025-12-10 02:09:46 +03:00
Egor 5a3bded4a8 Merge pull request #2136 from BEDOLAGA-DEV/revert-2134-dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Revert "Handle FAQ status update without request body"
2025-12-10 02:09:00 +03:00
Egor 2743a845bd Revert "Handle FAQ status update without request body" 2025-12-10 02:08:51 +03:00
Egor 7c222c0aed Merge pull request #2135 from BEDOLAGA-DEV/dev5
Dev5
2025-12-09 23:49:34 +03:00
Egor 3222c82b46 Merge pull request #2134 from BEDOLAGA-DEV/dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Handle FAQ status update without request body
2025-12-09 23:49:16 +03:00
Egor 9f0f3a367c Handle FAQ status update without request body 2025-12-09 23:48:54 +03:00
Ivan.Nginx f702356f48 fix: healthcheck command to include API key in requests 2025-12-09 03:14:24 +03:00
Ivan.Nginx 48d799e312 Reduce minimum amount for Platega transactions from 10000 to 100 kopeks (from 100 to 1 rub) 2025-12-09 00:59:29 +03:00
35 changed files with 2178 additions and 173 deletions
+15 -1
View File
@@ -19,6 +19,20 @@ ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Мониторинг трафика
TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление)
TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24)
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат)
# Черный список
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
@@ -343,7 +357,7 @@ PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.9.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.0"
ARG VERSION="v2.9.1"
ARG BUILD_DATE
ARG VCS_REF
+3 -3
View File
@@ -272,7 +272,7 @@ services:
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
interval: 60s
timeout: 10s
retries: 3
@@ -400,7 +400,7 @@ services:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
interval: 60s
timeout: 10s
retries: 3
@@ -971,7 +971,7 @@ PLATEGA_SECRET=your_secret_key
PLATEGA_RETURN_URL=https://your-domain.com/payments/success
PLATEGA_FAILED_URL=https://your-domain.com/payments/failed
PLATEGA_ACTIVE_METHODS=2,10,11
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=5000000
PLATEGA_CURRENCY=RUB
PLATEGA_WEBHOOK_PATH=/platega-webhook
+4
View File
@@ -31,6 +31,8 @@ from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
blacklist as admin_blacklist,
bulk_ban as admin_bulk_ban,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
@@ -176,6 +178,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
+13 -1
View File
@@ -155,12 +155,24 @@ class Settings(BaseSettings):
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
BLACKLIST_CHECK_ENABLED: bool = False
BLACKLIST_GITHUB_URL: Optional[str] = None
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
BLACKLIST_IGNORE_ADMINS: bool = True
# Настройки мониторинга трафика
TRAFFIC_MONITORING_ENABLED: bool = False
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки)
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
AUTOPAY_WARNING_DAYS: str = "3,1"
DEFAULT_AUTOPAY_ENABLED: bool = False
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
MONITORING_INTERVAL: int = 60
INACTIVE_USER_DELETE_MONTHS: int = 3
+19
View File
@@ -119,6 +119,25 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Opt
return user
async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.remnawave_uuid == remnawave_uuid)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def create_unique_referral_code(db: AsyncSession) -> str:
max_attempts = 10
+95 -20
View File
@@ -391,12 +391,14 @@ class RemnaWaveAPI:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('POST', '/api/users', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/{uuid}')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
@@ -408,7 +410,8 @@ class RemnaWaveAPI:
users_data = response.get('response', [])
if not users_data:
return []
return [self._parse_user(user) for user in users_data]
users = [self._parse_user(user) for user in users_data]
return [await self.enrich_user_with_happ_link(u) for u in users]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
@@ -417,7 +420,8 @@ class RemnaWaveAPI:
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-username/{username}')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
@@ -461,38 +465,57 @@ class RemnaWaveAPI:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('PATCH', '/api/users', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/users/{uuid}')
return response['response']['isDeleted']
async def enable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def disable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
"""
Получает список всех пользователей.
Args:
start: Смещение для пагинации
size: Размер страницы
enrich_happ_links: Если True, для каждого пользователя будет запрошена
зашифрованная happ ссылка (медленно для больших списков)
"""
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
users = [self._parse_user(user) for user in response['response']['users']]
if enrich_happ_links:
users = [await self.enrich_user_with_happ_link(u) for u in users]
return {
'users': [self._parse_user(user) for user in response['response']['users']],
'users': users,
'total': response['response']['total']
}
@@ -600,7 +623,13 @@ class RemnaWaveAPI:
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
return self._parse_subscription_info(response['response'])
info = self._parse_subscription_info(response['response'])
# Обогащаем happ_crypto_link если его нет но есть subscription_url
if not info.happ_crypto_link and info.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(info.subscription_url)
if encrypted:
info.happ_crypto_link = encrypted
return info
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
@@ -656,6 +685,25 @@ class RemnaWaveAPI:
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return response['response']
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
"""
Получает статистику использования трафика пользователем за указанный период
Args:
user_uuid: UUID пользователя
start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z")
end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z")
Returns:
Словарь с информацией о трафике пользователя за указанный период
"""
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params)
return response
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
@@ -706,8 +754,35 @@ class RemnaWaveAPI:
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
"""
Шифрует ссылку подписки через API Remnawave.
Возвращает зашифрованную happ:// ссылку или None при ошибке.
"""
try:
data = {"linkToEncrypt": link_to_encrypt}
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
return response.get('response', {}).get('encryptedLink')
except RemnaWaveAPIError as e:
logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}")
return None
except Exception as e:
logger.warning(f"Ошибка при шифровании happ ссылки: {e}")
return None
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
"""
Обогащает объект пользователя зашифрованной happ ссылкой,
если она отсутствует но есть subscription_url.
"""
if not user.happ_crypto_link and user.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
if encrypted:
user.happ_crypto_link = encrypted
return user
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
"""Парсит данные трафика из нового формата API"""
if not traffic_data:
+36 -1
View File
@@ -1 +1,36 @@
# Инициализация админских обработчиков
# Инициализация админских обработчиков
from . import (
backup,
blacklist,
bot_configuration,
bulk_ban,
campaigns,
faq,
main,
maintenance,
messages,
monitoring,
payments,
polls,
pricing,
privacy_policy,
promo_groups,
promo_offers,
promocodes,
public_offer,
referrals,
remnawave,
reports,
rules,
servers,
statistics,
subscriptions,
support_settings,
system_logs,
tickets,
trials,
updates,
user_messages,
users,
welcome_text,
)
+365
View File
@@ -0,0 +1,365 @@
"""
Обработчики админ-панели для управления черным списком
"""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import get_admin_users_keyboard
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_blacklist_settings(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Показывает настройки черного списка
"""
logger.info(f"Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}")
is_enabled = blacklist_service.is_blacklist_check_enabled()
github_url = blacklist_service.get_blacklist_github_url()
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
status_text = "✅ Включена" if is_enabled else "❌ Отключена"
url_text = github_url if github_url else "Не задан"
text = f"""
🔐 <b>Настройки черного списка</b>
Статус: {status_text}
URL к черному списку: <code>{url_text}</code>
Количество записей: {blacklist_count}
Действия:
"""
keyboard = [
[
types.InlineKeyboardButton(
text="🔄 Обновить список" if is_enabled else "🔄 Обновить (откл.)",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="📋 Просмотреть список" if is_enabled else "📋 Просмотр (откл.)",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="✏️ URL к GitHub" if not github_url else "✏️ Изменить URL",
callback_data="admin_blacklist_set_url"
)
],
[
types.InlineKeyboardButton(
text="✅ Включить" if not is_enabled else "❌ Отключить",
callback_data="admin_blacklist_toggle"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад к пользователям",
callback_data="admin_users"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def toggle_blacklist(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Переключает статус проверки черного списка
"""
# Текущая реализация использует настройки из .env
# Для полной реализации нужно будет создать сервис настроек
is_enabled = blacklist_service.is_blacklist_check_enabled()
# В реальной реализации нужно будет изменить настройку в базе данных
# или в системе настроек, но сейчас просто покажем статус
new_status = not is_enabled
status_text = "включена" if new_status else "отключена"
await callback.message.edit_text(
f"Статус проверки черного списка: {status_text}\n\n"
f"Для изменения статуса проверки черного списка измените значение\n"
f"<code>BLACKLIST_CHECK_ENABLED</code> в файле <code>.env</code>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить статус",
callback_data="admin_blacklist_settings"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def update_blacklist(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обновляет черный список из GitHub
"""
success, message = await blacklist_service.force_update_blacklist()
if success:
await callback.message.edit_text(
f"{message}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="📋 Просмотреть список",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="🔄 Ручное обновление",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
else:
await callback.message.edit_text(
f"❌ Ошибка обновления: {message}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Повторить",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def show_blacklist_users(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Показывает список пользователей в черном списке
"""
blacklist_users = await blacklist_service.get_all_blacklisted_users()
if not blacklist_users:
text = "Черный список пуст"
else:
text = f"🔐 <b>Черный список ({len(blacklist_users)} записей)</b>\n\n"
# Показываем первые 20 записей
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
text += f"{i}. <code>{tg_id}</code> {username or ''}{reason}\n"
if len(blacklist_users) > 20:
text += f"\n... и еще {len(blacklist_users) - 20} записей"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def start_set_blacklist_url(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Начинает процесс установки URL к черному списку
"""
current_url = blacklist_service.get_blacklist_github_url() or "не задан"
await callback.message.edit_text(
f"Введите новый URL к файлу черного списка на GitHub\n\n"
f"Текущий URL: {current_url}\n\n"
f"Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n"
f"Для отмены используйте команду /cancel",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.set_state("waiting_for_blacklist_url")
await callback.answer()
@admin_required
@error_handler
async def process_blacklist_url(
message: types.Message,
db_user: User,
state: FSMContext
):
"""
Обрабатывает введенный URL к черному списку
"""
url = message.text.strip()
# В реальной реализации нужно сохранить URL в систему настроек
# В текущей реализации просто выводим сообщение
if url.lower() in ['/cancel', 'отмена', 'cancel']:
await message.answer(
"Настройка URL отменена",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.clear()
return
# Проверяем, что URL выглядит корректно
if not url.startswith(('http://', 'https://')):
await message.answer(
"❌ Некорректный URL. URL должен начинаться с http:// или https://",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
return
# В реальной системе здесь нужно сохранить URL в базу данных настроек
# или в систему конфигурации
await message.answer(
f"✅ URL к черному списку установлен:\n<code>{url}</code>\n\n"
f"Для применения изменений перезапустите бота или измените значение\n"
f"<code>BLACKLIST_GITHUB_URL</code> в файле <code>.env</code>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить список",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.clear()
def register_blacklist_handlers(dp):
"""
Регистрация обработчиков черного списка
"""
# Обработчик показа настроек черного списка
# Этот обработчик нужно будет вызывать из меню пользователей или отдельно
dp.callback_query.register(
show_blacklist_settings,
lambda c: c.data == "admin_blacklist_settings"
)
# Обработчики для взаимодействия с черным списком
dp.callback_query.register(
toggle_blacklist,
lambda c: c.data == "admin_blacklist_toggle"
)
dp.callback_query.register(
update_blacklist,
lambda c: c.data == "admin_blacklist_update"
)
dp.callback_query.register(
show_blacklist_users,
lambda c: c.data == "admin_blacklist_view"
)
dp.callback_query.register(
start_set_blacklist_url,
lambda c: c.data == "admin_blacklist_set_url"
)
# Обработчик сообщений для установки URL (работает только в нужном состоянии)
dp.message.register(
process_blacklist_url,
lambda m: True # Фильтр будет внутри функции
)
+178
View File
@@ -0,0 +1,178 @@
"""
Обработчики команд для массовой блокировки пользователей
"""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.bulk_ban_service import bulk_ban_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import get_admin_users_keyboard
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def start_bulk_ban_process(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Начало процесса массовой блокировки пользователей
"""
await callback.message.edit_text(
"🛑 <b>Массовая блокировка пользователей</b>\n\n"
"Введите список Telegram ID для блокировки.\n\n"
"<b>Форматы ввода:</b>\n"
"• По одному ID на строку\n"
"• Через запятую\n"
"• Через пробел\n\n"
"Пример:\n"
"<code>123456789\n"
"987654321\n"
"111222333</code>\n\n"
"Или:\n"
"<code>123456789, 987654321, 111222333</code>\n\n"
"Для отмены используйте команду /cancel",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")]
])
)
await state.set_state(AdminStates.waiting_for_bulk_ban_list)
await callback.answer()
@admin_required
@error_handler
async def process_bulk_ban_list(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""
Обработка списка Telegram ID и выполнение массовой блокировки
"""
if not message.text:
await message.answer(
"❌ Отправьте текстовое сообщение со списком Telegram ID",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
input_text = message.text.strip()
if not input_text:
await message.answer(
"❌ Введите корректный список Telegram ID",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
# Парсим ID из текста
try:
telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text)
except Exception as e:
logger.error(f"Ошибка парсинга Telegram ID: {e}")
await message.answer(
"❌ Ошибка при обработке списка ID. Проверьте формат ввода.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
if not telegram_ids:
await message.answer(
"❌ Не найдено корректных Telegram ID в списке",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
if len(telegram_ids) > 1000: # Ограничение на количество ID за раз
await message.answer(
f"❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
# Выполняем массовую блокировку
try:
successfully_banned, not_found, error_ids = await bulk_ban_service.ban_users_by_telegram_ids(
db=db,
admin_user_id=db_user.id,
telegram_ids=telegram_ids,
reason="Массовая блокировка администратором",
bot=message.bot,
notify_admin=True,
admin_name=db_user.full_name
)
# Подготавливаем сообщение с результатами
result_text = f"✅ <b>Массовая блокировка завершена</b>\n\n"
result_text += f"📊 <b>Результаты:</b>\n"
result_text += f"✅ Успешно заблокировано: {successfully_banned}\n"
result_text += f"❌ Не найдено: {not_found}\n"
result_text += f"💥 Ошибок: {len(error_ids)}\n\n"
result_text += f"📈 Всего обработано: {len(telegram_ids)}"
if successfully_banned > 0:
result_text += f"\n🎯 Процент успеха: {round((successfully_banned/len(telegram_ids))*100, 1)}%"
# Добавляем информацию об ошибках, если есть
if error_ids:
result_text += f"\n\n⚠️ <b>Telegram ID с ошибками:</b>\n"
result_text += f"<code>{', '.join(map(str, error_ids[:10]))}</code>" # Показываем первые 10
if len(error_ids) > 10:
result_text += f" и еще {len(error_ids) - 10}..."
await message.answer(
result_text,
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="👥 К пользователям", callback_data="admin_users")]
])
)
except Exception as e:
logger.error(f"Ошибка при выполнении массовой блокировки: {e}")
await message.answer(
"❌ Произошла ошибка при выполнении массовой блокировки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
await state.clear()
def register_bulk_ban_handlers(dp):
"""
Регистрация обработчиков команд для массовой блокировки
"""
# Обработчик команды начала массовой блокировки
dp.callback_query.register(
start_bulk_ban_process,
lambda c: c.data == "admin_bulk_ban_start"
)
# Обработчик текстового сообщения с ID для блокировки
dp.message.register(
process_bulk_ban_list,
AdminStates.waiting_for_bulk_ban_list
)
+164 -2
View File
@@ -299,6 +299,137 @@ async def show_users_list_by_balance(
await callback.answer()
@admin_required
@error_handler
async def show_users_ready_to_renew(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
page: int = 1
):
"""Показывает пользователей с истекшей подпиской и балансом >= порога."""
await state.set_state(AdminStates.viewing_user_from_ready_to_renew_list)
texts = get_texts(db_user.language)
threshold = getattr(
settings,
"SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS",
20000,
)
user_service = UserService()
users_data = await user_service.get_users_ready_to_renew(
db,
min_balance_kopeks=threshold,
page=page,
limit=10,
)
amount_text = settings.format_price(threshold)
header = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_TITLE",
"♻️ Пользователи готовы к продлению",
)
description = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_DESC",
"Подписка истекла, а на балансе осталось {amount} или больше.",
).format(amount=amount_text)
if not users_data["users"]:
empty_text = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY",
"Сейчас нет пользователей, которые подходят под этот фильтр.",
)
await callback.message.edit_text(
f"{header}\n\n{description}\n\n{empty_text}",
reply_markup=get_admin_users_keyboard(db_user.language),
)
await callback.answer()
return
text = f"{header}\n\n{description}\n\n"
text += "Нажмите на пользователя для управления:"
keyboard = []
current_time = datetime.utcnow()
for user in users_data["users"]:
subscription = user.subscription
status_emoji = "" if user.status == UserStatus.ACTIVE.value else "🚫"
subscription_emoji = ""
expired_days = "?"
if subscription:
if subscription.is_trial:
subscription_emoji = "🎁"
elif subscription.is_active:
subscription_emoji = "💎"
else:
subscription_emoji = ""
if subscription.end_date:
delta = current_time - subscription.end_date
expired_days = delta.days
button_text = (
f"{status_emoji} {subscription_emoji} {user.full_name}"
f" | 💰 {settings.format_price(user.balance_kopeks)}"
f" | ⏰ {expired_days}д ист."
)
if len(button_text) > 60:
short_name = user.full_name
if len(short_name) > 20:
short_name = short_name[:17] + "..."
button_text = (
f"{status_emoji} {subscription_emoji} {short_name}"
f" | 💰 {settings.format_price(user.balance_kopeks)}"
)
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if users_data["total_pages"] > 1:
pagination_row = get_admin_pagination_keyboard(
users_data["current_page"],
users_data["total_pages"],
"admin_users_ready_to_renew_list",
"admin_users_ready_to_renew_filter",
db_user.language,
).inline_keyboard[0]
keyboard.append(pagination_row)
keyboard.extend([
[
types.InlineKeyboardButton(
text="🔍 Поиск",
callback_data="admin_users_search",
),
types.InlineKeyboardButton(
text="📊 Статистика",
callback_data="admin_users_stats",
),
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_users",
)
],
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_users_list_by_traffic(
@@ -859,6 +990,22 @@ async def handle_users_purchases_list_pagination(
await show_users_list_by_purchases(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_ready_to_renew_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
try:
page = int(callback.data.split('_')[-1])
await show_users_ready_to_renew(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка парсинга номера страницы: {e}")
await show_users_ready_to_renew(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_campaign_list_pagination(
@@ -1502,6 +1649,8 @@ async def show_user_management(
back_callback = "admin_users_purchases_filter"
elif current_state == AdminStates.viewing_user_from_campaign_list:
back_callback = "admin_users_campaign_filter"
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
back_callback = "admin_users_ready_to_renew_filter"
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -4559,10 +4708,13 @@ async def admin_buy_subscription_execute(
target_user.telegram_id,
)
if subscription.end_date <= current_time:
extension_base_date = current_time
if subscription.end_date and subscription.end_date > current_time:
extension_base_date = subscription.end_date
else:
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
@@ -4857,6 +5009,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_users_purchases_list_page_")
)
dp.callback_query.register(
handle_users_ready_to_renew_pagination,
F.data.startswith("admin_users_ready_to_renew_list_page_")
)
dp.callback_query.register(
handle_users_campaign_list_pagination,
F.data.startswith("admin_users_campaign_list_page_")
@@ -5128,6 +5285,11 @@ def register_handlers(dp: Dispatcher):
show_users_list_by_purchases,
F.data == "admin_users_purchases_filter"
)
dp.callback_query.register(
show_users_ready_to_renew,
F.data == "admin_users_ready_to_renew_filter"
)
dp.callback_query.register(
show_users_list_by_campaign,
+20 -1
View File
@@ -7,6 +7,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
@@ -96,8 +97,26 @@ async def process_cryptobot_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
+19
View File
@@ -6,6 +6,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -68,6 +69,24 @@ async def process_stars_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
+39 -2
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
@@ -133,8 +134,26 @@ async def process_yookassa_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
@@ -261,8 +280,26 @@ async def process_yookassa_sbp_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
+19
View File
@@ -7,6 +7,7 @@ from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
@@ -79,6 +80,24 @@ async def process_promocode(
state: FSMContext,
db: AsyncSession
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Активация промокода невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
code = message.text.strip()
+47 -6
View File
@@ -42,6 +42,7 @@ from app.utils.promo_offer import (
from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
from app.services.blacklist_service import blacklist_service
logger = logging.getLogger(__name__)
@@ -1000,13 +1001,33 @@ async def process_referral_code_skip(
async def complete_registration_from_callback(
callback: types.CallbackQuery,
state: FSMContext,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.message.answer(
f"🚫 Регистрация невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
await state.clear()
return
from sqlalchemy.orm import selectinload
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1255,12 +1276,32 @@ async def complete_registration_from_callback(
async def complete_registration(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Регистрация невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
+133 -74
View File
@@ -51,6 +51,7 @@ from app.services.user_cart_service import user_cart_service
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
from app.services.blacklist_service import blacklist_service
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
@@ -995,7 +996,7 @@ async def save_cart_and_redirect_to_topup(
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -1020,7 +1021,7 @@ async def return_to_saved_cart(
):
# Получаем данные корзины из Redis
cart_data = await user_cart_service.get_user_cart(db_user.id)
if not cart_data:
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
return
@@ -1347,6 +1348,25 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Продление подписки невозможно\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
from app.services.admin_notification_service import AdminNotificationService
days = int(callback.data.split('_')[2])
@@ -1528,7 +1548,7 @@ async def confirm_extend_subscription(
'description': f"Продление подписки на {days} дней",
'consume_promo_offer': bool(promo_component["discount"] > 0),
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -1811,6 +1831,25 @@ async def confirm_purchase(
):
from app.services.admin_notification_service import AdminNotificationService
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Покупка подписки невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
data = await state.get_data()
texts = get_texts(db_user.language)
@@ -2101,7 +2140,7 @@ async def confirm_purchase(
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -2210,36 +2249,37 @@ async def confirm_purchase(
if should_update_devices:
existing_subscription.device_limit = selected_devices
# Проверяем, что при обновлении существующей подписки есть хотя бы одна страна
selected_countries = data.get('countries', [])
selected_countries = data.get('countries')
if not selected_countries:
# В случае если подписка уже существовала, не разрешаем отключать все страны
# Если подписка новая, разрешаем, но обычно через UI пользователь должен выбрать хотя бы один сервер
if existing_subscription and existing_subscription.connected_squads is not None:
# Проверим, что в данных есть информация о том, что это обновление существующей подписки
# или что-то указывает, что не нужно отключать все страны
pass # Для простоты в этом случае просто проверим, что список стран не пустой
else:
# Для новой подписки разрешаем пустой список, если не является обновлением
pass
# Иногда после возврата к оформлению из сохраненной корзины список стран не передается.
# В таком случае повторно используем текущие подключенные страны подписки.
selected_countries = existing_subscription.connected_squads or []
if selected_countries:
data['countries'] = selected_countries # чтобы далее использовать фактический список стран
# Но для безопасности - если список стран пустой, проверим, что это разрешено
# иначе вернем ошибку
if not selected_countries:
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
if not selected_countries:
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
existing_subscription.connected_squads = selected_countries
existing_subscription.start_date = current_time
existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
# Если подписка еще активна, продлеваем от текущей даты окончания,
# иначе начинаем новый период с текущего момента
extension_base_date = current_time
if existing_subscription.end_date and existing_subscription.end_date > current_time:
extension_base_date = existing_subscription.end_date
else:
existing_subscription.start_date = current_time
existing_subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period
existing_subscription.updated_at = current_time
existing_subscription.traffic_used_gb = 0.0
@@ -2266,7 +2306,7 @@ async def confirm_purchase(
resolved_device_limit = default_device_limit
# Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран
new_subscription_countries = data.get('countries', [])
new_subscription_countries = data.get('countries')
if not new_subscription_countries:
# Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну
# Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну
@@ -2304,11 +2344,11 @@ async def confirm_purchase(
await add_user_to_servers(db, server_ids)
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
await db.refresh(db_user)
subscription_service = SubscriptionService()
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
@@ -2323,7 +2363,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки",
)
if not remnawave_user:
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(
@@ -2332,7 +2372,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки (повторная попытка)",
)
transaction = await create_transaction(
db=db,
user_id=db_user.id,
@@ -2939,7 +2979,7 @@ def register_handlers(dp: Dispatcher):
show_device_connection_help,
F.data == "device_connection_help"
)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
@@ -2954,12 +2994,31 @@ async def handle_simple_subscription_purchase(
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Простая покупка подписки невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Определяем ограничение по устройствам для текущего режима
simple_device_limit = resolve_simple_subscription_device_limit()
@@ -2989,10 +3048,10 @@ async def handle_simple_subscription_purchase(
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
@@ -3017,7 +3076,7 @@ async def handle_simple_subscription_purchase(
if subscription_params["traffic_limit_gb"] == 0
else f"{subscription_params['traffic_limit_gb']} ГБ"
)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
simple_lines = [
@@ -3040,7 +3099,7 @@ async def handle_simple_subscription_purchase(
])
message_text = "\n".join(simple_lines)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
@@ -3068,19 +3127,19 @@ async def handle_simple_subscription_purchase(
])
message_text = "\n".join(simple_lines)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
async def _calculate_simple_subscription_price(
@@ -3105,14 +3164,14 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
@@ -3126,38 +3185,38 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -3179,9 +3238,9 @@ async def _extend_existing_subscription(
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import calculate_months_from_days
from datetime import datetime, timedelta
texts = get_texts(db_user.language)
# Рассчитываем цену подписки
subscription_params = {
"period_days": period_days,
@@ -3205,7 +3264,7 @@ async def _extend_existing_subscription(
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
)
# Проверяем баланс пользователя
if db_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - db_user.balance_kopeks
@@ -3223,7 +3282,7 @@ async def _extend_existing_subscription(
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
# Подготовим данные для сохранения в корзину
from app.services.user_cart_service import user_cart_service
cart_data = {
@@ -3241,9 +3300,9 @@ async def _extend_existing_subscription(
'squad_uuid': squad_uuid,
'consume_promo_offer': False,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
@@ -3255,7 +3314,7 @@ async def _extend_existing_subscription(
)
await callback.answer()
return
# Списываем средства
success = await subtract_user_balance(
db,
@@ -3264,15 +3323,15 @@ async def _extend_existing_subscription(
f"Продление подписки на {period_days} дней",
consume_promo_offer=False, # Простая покупка не использует промо-скидки
)
if not success:
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
return
# Обновляем параметры подписки
current_time = datetime.utcnow()
old_end_date = current_subscription.end_date
# Обновляем параметры в зависимости от типа текущей подписки
if current_subscription.is_trial:
# При продлении триальной подписки переводим её в обычную
@@ -3296,7 +3355,7 @@ async def _extend_existing_subscription(
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
# Используем += для безопасного добавления в список SQLAlchemy
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
# Продлеваем подписку
if current_subscription.end_date > current_time:
# Если подписка ещё активна, добавляем дни к текущей дате окончания
@@ -3304,15 +3363,15 @@ async def _extend_existing_subscription(
else:
# Если подписка уже истекла, начинаем от текущего времени
new_end_date = current_time + timedelta(days=period_days)
current_subscription.end_date = new_end_date
current_subscription.updated_at = current_time
# Сохраняем изменения
await db.commit()
await db.refresh(current_subscription)
await db.refresh(db_user)
# Обновляем пользователя в Remnawave
subscription_service = SubscriptionService()
try:
@@ -3328,7 +3387,7 @@ async def _extend_existing_subscription(
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
except Exception as e:
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
# Создаём транзакцию
transaction = await create_transaction(
db=db,
@@ -3337,7 +3396,7 @@ async def _extend_existing_subscription(
amount_kopeks=price_kopeks,
description=f"Продление подписки на {period_days} дней"
)
# Отправляем уведомление админу
try:
notification_service = AdminNotificationService(callback.bot)
@@ -3353,7 +3412,7 @@ async def _extend_existing_subscription(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
# Отправляем сообщение пользователю
success_message = (
"✅ Подписка успешно продлена!\n\n"
@@ -3361,15 +3420,15 @@ async def _extend_existing_subscription(
f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n"
f"💰 Списано: {texts.format_price(price_kopeks)}"
)
# Если это была триальная подписка, добавляем информацию о преобразовании
if current_subscription.is_trial:
success_message += "\n🎯 Триальная подписка преобразована в платную"
await callback.message.edit_text(
success_message,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}")
await callback.answer()
+18
View File
@@ -320,6 +320,18 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_users_filters"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_BLACKLIST", "🔐 Черный список"),
callback_data="admin_blacklist_settings"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_BULK_BAN", "🛑 Массовый бан"),
callback_data="admin_bulk_ban_start"
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users")
]
@@ -360,6 +372,12 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark
callback_data="admin_users_purchases_filter"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"),
callback_data="admin_users_ready_to_renew_filter"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"),
+7 -1
View File
@@ -711,6 +711,10 @@
"ADMIN_USERS_FILTERS": "⚙️ Filters",
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity",
"ADMIN_USERS_FILTER_BALANCE": "💰 By balance",
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Ready to renew",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Users ready to renew",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Their subscription expired and the balance still has {amount} or more.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "No users match this filter right now.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases",
"ADMIN_USERS_FILTER_SPENDING": "💳 By spending",
@@ -1528,5 +1532,7 @@
"POLL_EMPTY": "Poll is not available yet.",
"POLL_ERROR": "Unable to process the poll. Please try again later.",
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
}
+7 -1
View File
@@ -712,6 +712,10 @@
"ADMIN_USERS_FILTERS": "⚙️ Фильтры",
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности",
"ADMIN_USERS_FILTER_BALANCE": "💰 По балансу",
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
@@ -1540,5 +1544,7 @@
"POLL_EMPTY": "Опрос пока недоступен.",
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
}
+7 -3
View File
@@ -710,8 +710,12 @@
"ADMIN_USERS_ALL": "👥 Всі користувачі",
"ADMIN_USERS_FILTERS": "⚙️ Фільтри",
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 За активністю",
"ADMIN_USERS_FILTER_BALANCE": "💰 За балансом",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією",
"ADMIN_USERS_FILTER_BALANCE": "💰 За балансом",
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готові до продовження",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Користувачі, готові до продовження",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Підписка вже закінчилась, а на балансі залишилось {amount} або більше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Наразі немає користувачів, які підходять під цей фільтр.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 За кількістю покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 За сумою витрат",
"ADMIN_USERS_FILTER_TRAFFIC": "📶 За трафіком",
@@ -1532,4 +1536,4 @@
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
}
}
+5 -1
View File
@@ -710,6 +710,10 @@
"ADMIN_USERS_FILTERS":"⚙️筛选器",
"ADMIN_USERS_FILTER_ACTIVITY":"🕒按活跃度",
"ADMIN_USERS_FILTER_BALANCE":"💰按余额",
"ADMIN_USERS_FILTER_RENEW_READY":"♻️准备续费",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE":"♻️准备续费的用户",
"ADMIN_USERS_FILTER_RENEW_READY_DESC":"订阅已到期,但余额仍不少于{amount}。",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY":"目前没有符合条件的用户。",
"ADMIN_USERS_FILTER_CAMPAIGN":"📢按活动",
"ADMIN_USERS_FILTER_PURCHASES":"🛒按购买次数",
"ADMIN_USERS_FILTER_SPENDING":"💳按消费金额",
@@ -1860,4 +1864,4 @@
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
"DEVICE_GUIDE_WINDOWS":"💻Windows",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
}
}
@@ -1451,6 +1451,50 @@ class AdminNotificationService:
return str(value)
return str(value)
async def send_bulk_ban_notification(
self,
admin_user_id: int,
successfully_banned: int,
not_found: int,
errors: int,
admin_name: str = "Администратор"
) -> bool:
"""Отправляет уведомление о массовой блокировке пользователей"""
if not self._is_enabled():
return False
try:
message_lines = [
"🛑 <b>МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ</b>",
"",
f"👮 <b>Администратор:</b> {admin_name}",
f"🆔 <b>ID администратора:</b> {admin_user_id}",
"",
"📊 <b>Результаты:</b>",
f"✅ Успешно заблокировано: {successfully_banned}",
f"❌ Не найдено: {not_found}",
f"💥 Ошибок: {errors}"
]
total_processed = successfully_banned + not_found + errors
if total_processed > 0:
success_rate = (successfully_banned / total_processed) * 100
message_lines.append(f"📈 Успешность: {success_rate:.1f}%")
message_lines.extend(
[
"",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
message = "\n".join(message_lines)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о массовой блокировке: {e}")
return False
async def send_ticket_event_notification(
self,
text: str,
@@ -1468,3 +1512,49 @@ class AdminNotificationService:
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
async def send_suspicious_traffic_notification(
self,
message: str,
bot: Bot,
topic_id: Optional[int] = None
) -> bool:
"""
Отправляет уведомление о подозрительной активности трафика
Args:
message: текст уведомления
bot: экземпляр бота для отправки сообщения
topic_id: ID топика для отправки уведомления (если не указан, использует стандартный)
"""
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
return False
# Используем специальный топик для подозрительной активности, если он задан
notification_topic_id = topic_id or self.topic_id
try:
message_kwargs = {
'chat_id': self.chat_id,
'text': message,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
if notification_topic_id:
message_kwargs['message_thread_id'] = notification_topic_id
await bot.send_message(**message_kwargs)
logger.info(f"Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}")
return True
except TelegramForbiddenError:
logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}")
return False
except TelegramBadRequest as e:
logger.error(f"Ошибка отправки уведомления о подозрительной активности: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}")
return False
+230
View File
@@ -0,0 +1,230 @@
"""
Сервис для работы с черным списком пользователей
Проверяет пользователей по списку из GitHub репозитория
"""
import asyncio
import logging
import re
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class BlacklistService:
"""
Сервис для проверки пользователей по черному списку
"""
def __init__(self):
self.blacklist_data = [] # Список в формате [(telegram_id, username, reason), ...]
self.last_update = None
# Используем интервал из настроек, по умолчанию 24 часа
interval_hours = self.get_blacklist_update_interval_hours()
self.update_interval = timedelta(hours=interval_hours)
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
def is_blacklist_check_enabled(self) -> bool:
"""Проверяет, включена ли проверка черного списка"""
return getattr(settings, 'BLACKLIST_CHECK_ENABLED', False)
def get_blacklist_github_url(self) -> Optional[str]:
"""Получает URL к файлу черного списка на GitHub"""
return getattr(settings, 'BLACKLIST_GITHUB_URL', None)
def get_blacklist_update_interval_hours(self) -> int:
"""Получает интервал обновления черного списка в часах"""
return getattr(settings, 'BLACKLIST_UPDATE_INTERVAL_HOURS', 24)
def should_ignore_admins(self) -> bool:
"""Проверяет, нужно ли игнорировать администраторов при проверке черного списка"""
return getattr(settings, 'BLACKLIST_IGNORE_ADMINS', True)
def is_admin(self, telegram_id: int) -> bool:
"""Проверяет, является ли пользователь администратором"""
return settings.is_admin(telegram_id)
async def update_blacklist(self) -> bool:
"""
Обновляет черный список из GitHub репозитория
"""
async with self.lock:
github_url = self.get_blacklist_github_url()
if not github_url:
logger.warning("URL к черному списку не задан в настройках")
return False
try:
# Заменяем github.com на raw.githubusercontent.com для получения raw содержимого
if "github.com" in github_url:
raw_url = github_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
else:
raw_url = github_url
# Получаем содержимое файла
async with aiohttp.ClientSession() as session:
async with session.get(raw_url) as response:
if response.status != 200:
logger.error(f"Ошибка при получении черного списка: статус {response.status}")
return False
content = await response.text()
# Разбираем содержимое файла
blacklist_data = []
lines = content.splitlines()
for line_num, line in enumerate(lines, 1):
line = line.strip()
if not line or line.startswith('#'):
continue # Пропускаем пустые строки и комментарии
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
parts = line.split()
if not parts:
continue
try:
telegram_id = int(parts[0]) # Первое число - это Telegram ID
# Всё остальное - просто комментарий, не используем его для логики
# Но можем использовать первую часть после ID как username для отображения
username = ""
if len(parts) > 1:
# Берем вторую часть как username (если начинается с @)
if parts[1].startswith('@'):
username = parts[1]
# По умолчанию используем "Занесен в черный список", если нет другой информации
reason = "Занесен в черный список"
# Если есть запятая в строке, можем использовать часть после нее как причину
full_line_after_id = line[len(str(telegram_id)):].strip()
if ',' in full_line_after_id:
# Извлекаем причину после запятой
after_comma = full_line_after_id.split(',', 1)[1].strip()
reason = after_comma
blacklist_data.append((telegram_id, username, reason))
except ValueError:
# Если не удается преобразовать в число, это не ID
logger.warning(f"Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}")
self.blacklist_data = blacklist_data
self.last_update = datetime.utcnow()
logger.info(f"Черный список успешно обновлен. Найдено {len(blacklist_data)} записей")
return True
except ValueError as e:
logger.error(f"Ошибка при парсинге ID из черного списка: {e}")
return False
except Exception as e:
logger.error(f"Ошибка при обновлении черного списка: {e}")
return False
async def is_user_blacklisted(self, telegram_id: int, username: Optional[str] = None) -> Tuple[bool, Optional[str]]:
"""
Проверяет, находится ли пользователь в черном списке
Args:
telegram_id: Telegram ID пользователя
username: Username пользователя (опционально)
Returns:
Кортеж (в черном списке, причина)
"""
if not self.is_blacklist_check_enabled():
return False, None
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
if self.should_ignore_admins() and self.is_admin(telegram_id):
logger.info(f"Пользователь {telegram_id} является администратором, игнорируем проверку черного списка")
return False, None
# Если черный список пуст или устарел, обновляем его
interval_hours = self.get_blacklist_update_interval_hours()
required_interval = timedelta(hours=interval_hours)
if not self.blacklist_data or (self.last_update and
datetime.utcnow() - self.last_update > required_interval):
await self.update_blacklist()
# Проверяем по Telegram ID
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
logger.info(f"Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}")
return True, bl_reason
# Проверяем по username, если он передан
if username:
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username and (bl_username == username or bl_username == f"@{username}"):
logger.info(f"Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}")
return True, bl_reason
return False, None
async def get_all_blacklisted_users(self) -> List[Tuple[int, str, str]]:
"""
Возвращает весь черный список
"""
interval_hours = self.get_blacklist_update_interval_hours()
required_interval = timedelta(hours=interval_hours)
if not self.blacklist_data or (self.last_update and
datetime.utcnow() - self.last_update > required_interval):
await self.update_blacklist()
return self.blacklist_data.copy()
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[Tuple[int, str, str]]:
"""
Возвращает информацию о пользователе из черного списка по Telegram ID
Args:
telegram_id: Telegram ID пользователя
Returns:
Кортеж (telegram_id, username, reason) или None, если не найден
"""
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
return (bl_id, bl_username, bl_reason)
return None
async def get_user_by_username(self, username: str) -> Optional[Tuple[int, str, str]]:
"""
Возвращает информацию о пользователе из черного списка по username
Args:
username: Username пользователя
Returns:
Кортеж (telegram_id, username, reason) или None, если не найден
"""
# Проверяем как с @, так и без
username_with_at = f"@{username}" if not username.startswith('@') else username
username_without_at = username.lstrip('@')
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username == username_with_at or bl_username.lstrip('@') == username_without_at:
return (bl_id, bl_username, bl_reason)
return None
async def force_update_blacklist(self) -> Tuple[bool, str]:
"""
Принудительно обновляет черный список
Returns:
Кортеж (успешно, сообщение)
"""
success = await self.update_blacklist()
if success:
return True, f"Черный список обновлен успешно. Записей: {len(self.blacklist_data)}"
else:
return False, "Ошибка обновления черного списка"
# Глобальный экземпляр сервиса
blacklist_service = BlacklistService()
+177
View File
@@ -0,0 +1,177 @@
"""
Модуль для массовой блокировки пользователей по списку Telegram ID
"""
import logging
from typing import List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from app.database.crud.user import get_user_by_telegram_id
from app.services.user_service import UserService
from app.services.admin_notification_service import AdminNotificationService
from app.config import settings
from app.database.models import UserStatus
logger = logging.getLogger(__name__)
class BulkBanService:
"""
Сервис для массовой блокировки пользователей по списку Telegram ID
"""
def __init__(self):
self.user_service = UserService()
async def ban_users_by_telegram_ids(
self,
db: AsyncSession,
admin_user_id: int,
telegram_ids: List[int],
reason: str = "Заблокирован администратором по списку",
bot: Bot = None,
notify_admin: bool = True,
admin_name: str = "Администратор"
) -> Tuple[int, int, List[int]]:
"""
Массовая блокировка пользователей по Telegram ID
Args:
db: Асинхронная сессия базы данных
admin_user_id: ID администратора, который осуществляет блокировку
telegram_ids: Список Telegram ID для блокировки
reason: Причина блокировки
bot: Бот для отправки уведомлений
notify_admin: Отправлять ли уведомления администратору
admin_name: Имя администратора для логирования
Returns:
Кортеж из (успешно заблокированных, не найденных, список ID с ошибками)
"""
successfully_banned = 0
not_found_users = []
error_ids = []
for telegram_id in telegram_ids:
try:
# Получаем пользователя по Telegram ID
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден")
not_found_users.append(telegram_id)
continue
# Проверяем, что пользователь не заблокирован уже
if user.status == UserStatus.BLOCKED.value:
logger.info(f"Пользователь {telegram_id} уже заблокирован")
continue
# Блокируем пользователя
ban_success = await self.user_service.block_user(
db, user.id, admin_user_id, reason
)
if ban_success:
successfully_banned += 1
logger.info(f"Пользователь {telegram_id} успешно заблокирован")
# Отправляем уведомление пользователю, если возможно
if bot:
try:
await bot.send_message(
chat_id=telegram_id,
text=(
f"🚫 <b>Ваш аккаунт заблокирован</b>\n\n"
f"Причина: {reason}\n\n"
f"Если вы считаете, что блокировка произошла ошибочно, "
f"обратитесь в поддержку."
),
parse_mode="HTML"
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление пользователю {telegram_id}: {e}")
else:
logger.error(f"Не удалось заблокировать пользователя {telegram_id}")
error_ids.append(telegram_id)
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {telegram_id}: {e}")
error_ids.append(telegram_id)
# Отправляем уведомление администратору
if notify_admin and bot:
try:
admin_notification_service = AdminNotificationService(bot)
await admin_notification_service.send_bulk_ban_notification(
admin_user_id,
successfully_banned,
len(not_found_users),
len(error_ids),
admin_name
)
except Exception as e:
logger.error(f"Ошибка при отправке уведомления администратору: {e}")
logger.info(
f"Массовая блокировка завершена: успешно={successfully_banned}, "
f"не найдено={len(not_found_users)}, ошибки={len(error_ids)}"
)
return successfully_banned, len(not_found_users), error_ids
async def parse_telegram_ids_from_text(self, text: str) -> List[int]:
"""
Парсит Telegram ID из текста. Поддерживает различные форматы:
- по одному ID на строку
- через запятую
- через пробелы
- с @username (если username соответствует формату ID)
"""
if not text:
return []
# Удаляем лишние пробелы и разбиваем по переносам строк
lines = text.strip().split('\n')
ids = []
for line in lines:
# Убираем комментарии и лишние пробелы
line = line.strip()
if not line or line.startswith('#'):
continue
# Разбиваем строку по запятым или пробелам
tokens = line.replace(',', ' ').split()
for token in tokens:
token = token.strip()
# Убираем символ @ если присутствует
if token.startswith('@'):
token = token[1:]
# Проверяем, является ли токен числом (Telegram ID)
try:
telegram_id = int(token)
if telegram_id > 0: # Telegram ID должны быть положительными
ids.append(telegram_id)
except ValueError:
# Пропускаем, если не является числом
continue
# Убираем дубликаты, сохранив порядок
unique_ids = []
seen = set()
for tid in ids:
if tid not in seen:
unique_ids.append(tid)
seen.add(tid)
return unique_ids
# Создаем глобальный экземпляр сервиса
bulk_ban_service = BulkBanService()
+1 -3
View File
@@ -1147,7 +1147,7 @@ class RemnaWaveService:
while True:
logger.info(f"📥 Загружаем пользователей: start={start}, size={size}")
response = await api.get_all_users(start=start, size=size)
response = await api.get_all_users(start=start, size=size, enrich_happ_links=True)
users_batch = response['users']
total_users = response['total']
@@ -2607,5 +2607,3 @@ class RemnaWaveService:
"api_url": settings.REMNAWAVE_API_URL,
"attempts_used": attempts,
}
@@ -1110,8 +1110,14 @@ class MiniAppSubscriptionPurchaseService:
subscription.traffic_limit_gb = pricing.selection.traffic_value
subscription.device_limit = pricing.selection.devices
subscription.connected_squads = pricing.selection.servers
subscription.start_date = now
subscription.end_date = now + timedelta(days=pricing.selection.period.days) + bonus_period
extension_base_date = now
if subscription.end_date and subscription.end_date > now:
extension_base_date = subscription.end_date
else:
subscription.start_date = now
subscription.end_date = extension_base_date + timedelta(days=pricing.selection.period.days) + bonus_period
subscription.updated_at = now
subscription.traffic_used_gb = 0.0
@@ -1229,4 +1235,3 @@ class SubscriptionPurchaseService:
purchase_service = MiniAppSubscriptionPurchaseService()
+332
View File
@@ -0,0 +1,332 @@
"""
Сервис для мониторинга трафика пользователей
Проверяет, не превышает ли пользователь заданный порог трафика за сутки
"""
import logging
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from decimal import Decimal
import aiohttp
from app.config import settings
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.database.crud.user import get_user_by_remnawave_uuid
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
class TrafficMonitoringService:
"""
Сервис для мониторинга трафика пользователей
"""
def __init__(self):
self.remnawave_service = RemnaWaveService()
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных проверок
def is_traffic_monitoring_enabled(self) -> bool:
"""Проверяет, включен ли мониторинг трафика"""
return getattr(settings, 'TRAFFIC_MONITORING_ENABLED', False)
def get_traffic_threshold_gb(self) -> float:
"""Получает порог трафика в ГБ за сутки"""
return getattr(settings, 'TRAFFIC_THRESHOLD_GB_PER_DAY', 10.0)
def get_monitoring_interval_hours(self) -> int:
"""Получает интервал мониторинга в часах"""
return getattr(settings, 'TRAFFIC_MONITORING_INTERVAL_HOURS', 24)
def get_suspicious_notifications_topic_id(self) -> Optional[int]:
"""Получает ID топика для уведомлений о подозрительной активности"""
return getattr(settings, 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID', None)
async def get_user_daily_traffic(self, user_uuid: str) -> Dict:
"""
Получает статистику трафика пользователя за последние 24 часа
Args:
user_uuid: UUID пользователя в Remnawave
Returns:
Словарь с информацией о трафике
"""
try:
# Получаем время начала и конца суток (сегодня)
now = datetime.utcnow()
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999)
# Форматируем даты в ISO формат
start_date = start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z")
end_date = end_of_day.strftime("%Y-%m-%dT%H:%M:%S.999Z")
# Получаем API клиент и вызываем метод получения статистики
async with self.remnawave_service.get_api_client() as api:
traffic_data = await api.get_user_stats_usage(user_uuid, start_date, end_date)
# Обрабатываем ответ API
if traffic_data and 'response' in traffic_data:
response = traffic_data['response']
# Вычисляем общий трафик
total_gb = 0
nodes_info = []
if isinstance(response, list):
for item in response:
node_name = item.get('nodeName', 'Unknown')
total_bytes = item.get('total', 0)
total_gb_item = round(total_bytes / (1024**3), 2) # Конвертируем в ГБ
total_gb += total_gb_item
nodes_info.append({
'node': node_name,
'gb': total_gb_item
})
else:
# Если response - это уже результат обработки (как в примере)
total_gb = response.get('total_gb', 0)
nodes_info = response.get('nodes', [])
return {
'total_gb': total_gb,
'nodes': nodes_info,
'date_range': {
'start': start_date,
'end': end_date
}
}
else:
logger.warning(f"Нет данных о трафике для пользователя {user_uuid}")
return {
'total_gb': 0,
'nodes': [],
'date_range': {
'start': start_date,
'end': end_date
}
}
except Exception as e:
logger.error(f"Ошибка при получении статистики трафика для {user_uuid}: {e}")
return {
'total_gb': 0,
'nodes': [],
'date_range': {
'start': None,
'end': None
}
}
async def check_user_traffic_threshold(
self,
db: AsyncSession,
user_uuid: str,
user_telegram_id: int = None
) -> Tuple[bool, Dict]:
"""
Проверяет, превышает ли трафик пользователя заданный порог
Args:
db: Сессия базы данных
user_uuid: UUID пользователя в Remnawave
user_telegram_id: Telegram ID пользователя (для логирования)
Returns:
Кортеж (превышен ли порог, информация о трафике)
"""
if not self.is_traffic_monitoring_enabled():
return False, {}
# Получаем статистику трафика
traffic_info = await self.get_user_daily_traffic(user_uuid)
total_gb = traffic_info.get('total_gb', 0)
# Получаем порог для сравнения
threshold_gb = self.get_traffic_threshold_gb()
# Проверяем, превышает ли трафик порог
is_exceeded = total_gb > threshold_gb
# Логируем проверку
user_id_info = f"telegram_id={user_telegram_id}" if user_telegram_id else f"uuid={user_uuid}"
status = "ПРЕВЫШЕНИЕ" if is_exceeded else "норма"
logger.info(
f"📊 Проверка трафика для {user_id_info}: {total_gb} ГБ, "
f"порог: {threshold_gb} ГБ, статус: {status}"
)
return is_exceeded, traffic_info
async def process_suspicious_traffic(
self,
db: AsyncSession,
user_uuid: str,
traffic_info: Dict,
bot
):
"""
Обрабатывает подозрительный трафик - отправляет уведомление администраторам
"""
try:
# Получаем информацию о пользователе из базы данных
user = await get_user_by_remnawave_uuid(db, user_uuid)
if not user:
logger.warning(f"Пользователь с UUID {user_uuid} не найден в базе данных")
return
# Формируем сообщение для администраторов
total_gb = traffic_info.get('total_gb', 0)
threshold_gb = self.get_traffic_threshold_gb()
message = (
f"⚠️ <b>Подозрительная активность трафика</b>\n\n"
f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n"
f"🔑 UUID: {user_uuid}\n"
f"📊 Трафик за сутки: <b>{total_gb} ГБ</b>\n"
f"📈 Порог: <b>{threshold_gb} ГБ</b>\n"
f"🚨 Превышение: <b>{total_gb - threshold_gb:.2f} ГБ</b>\n\n"
)
# Добавляем информацию по нодам, если есть
nodes = traffic_info.get('nodes', [])
if nodes:
message += "<b>Разбивка по нодам:</b>\n"
for node_info in nodes[:5]: # Показываем первые 5 нод
message += f"{node_info.get('node', 'Unknown')}: {node_info.get('gb', 0)} ГБ\n"
if len(nodes) > 5:
message += f" • и ещё {len(nodes) - 5} нод(ы)\n"
message += f"\n⏰ Время проверки: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S UTC')}"
# Создаем AdminNotificationService с ботом
admin_notification_service = AdminNotificationService(bot)
# Отправляем уведомление администраторам
topic_id = self.get_suspicious_notifications_topic_id()
await admin_notification_service.send_suspicious_traffic_notification(
message,
bot,
topic_id
)
logger.info(
f"✅ Уведомление о подозрительном трафике отправлено для пользователя {user.telegram_id}"
)
except Exception as e:
logger.error(f"❌ Ошибка при обработке подозрительного трафика для {user_uuid}: {e}")
async def check_all_users_traffic(self, db: AsyncSession, bot):
"""
Проверяет трафик всех пользователей с активной подпиской
"""
if not self.is_traffic_monitoring_enabled():
logger.info("Мониторинг трафика отключен, пропускаем проверку всех пользователей")
return
try:
from app.database.crud.user import get_users_with_active_subscriptions
# Получаем всех пользователей с активной подпиской
users = await get_users_with_active_subscriptions(db)
logger.info(f"Начинаем проверку трафика для {len(users)} пользователей")
# Проверяем трафик для каждого пользователя
for user in users:
if user.remnawave_uuid: # Проверяем только пользователей с UUID
is_exceeded, traffic_info = await self.check_user_traffic_threshold(
db,
user.remnawave_uuid,
user.telegram_id
)
if is_exceeded:
await self.process_suspicious_traffic(
db,
user.remnawave_uuid,
traffic_info,
bot
)
logger.info("Завершена проверка трафика всех пользователей")
except Exception as e:
logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}")
class TrafficMonitoringScheduler:
"""
Класс для планирования периодических проверок трафика
"""
def __init__(self, traffic_service: TrafficMonitoringService):
self.traffic_service = traffic_service
self.check_task = None
self.is_running = False
async def start_monitoring(self, db: AsyncSession, bot):
"""
Запускает периодическую проверку трафика
"""
if self.is_running:
logger.warning("Мониторинг трафика уже запущен")
return
if not self.traffic_service.is_traffic_monitoring_enabled():
logger.info("Мониторинг трафика отключен в настройках")
return
self.is_running = True
interval_hours = self.traffic_service.get_monitoring_interval_hours()
interval_seconds = interval_hours * 3600
logger.info(f"Запуск мониторинга трафика с интервалом {interval_hours} часов")
# Запускаем задачу с интервалом
self.check_task = asyncio.create_task(self._periodic_check(db, bot, interval_seconds))
async def stop_monitoring(self):
"""
Останавливает периодическую проверку трафика
"""
if self.check_task:
self.check_task.cancel()
try:
await self.check_task
except asyncio.CancelledError:
pass
self.is_running = False
logger.info("Мониторинг трафика остановлен")
async def _periodic_check(self, db: AsyncSession, bot, interval_seconds: int):
"""
Выполняет периодическую проверку трафика
"""
while self.is_running:
try:
logger.info("Запуск периодической проверки трафика")
await self.traffic_service.check_all_users_traffic(db, bot)
# Ждем указанный интервал перед следующей проверкой
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
logger.info("Задача периодической проверки трафика отменена")
break
except Exception as e:
logger.error(f"Ошибка в периодической проверке трафика: {e}")
# Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова
await asyncio.sleep(interval_seconds)
# Глобальные экземпляры сервисов
traffic_monitoring_service = TrafficMonitoringService()
traffic_monitoring_scheduler = TrafficMonitoringScheduler(traffic_monitoring_service)
+55
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update, func
from sqlalchemy.orm import selectinload
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
@@ -218,6 +219,60 @@ class UserService:
"has_prev": False
}
async def get_users_ready_to_renew(
self,
db: AsyncSession,
min_balance_kopeks: int,
page: int = 1,
limit: int = 20,
) -> Dict[str, Any]:
"""Возвращает пользователей с истекшей подпиской и достаточным балансом."""
try:
offset = (page - 1) * limit
now = datetime.utcnow()
base_filters = [
User.balance_kopeks >= min_balance_kopeks,
Subscription.end_date.isnot(None),
Subscription.end_date <= now,
]
query = (
select(User)
.options(selectinload(User.subscription))
.join(Subscription, Subscription.user_id == User.id)
.where(*base_filters)
.order_by(User.balance_kopeks.desc(), Subscription.end_date.asc())
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
users = result.scalars().all()
count_query = (
select(func.count(User.id))
.join(Subscription, Subscription.user_id == User.id)
.where(*base_filters)
)
total_count = (await db.execute(count_query)).scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 0
return {
"users": users,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
}
except Exception as e:
logger.error(f"Ошибка получения пользователей для продления: {e}")
return {
"users": [],
"current_page": 1,
"total_pages": 1,
"total_count": 0,
}
async def get_user_spending_stats_map(
self,
db: AsyncSession,
+2
View File
@@ -38,6 +38,7 @@ class PromoCodeStates(StatesGroup):
class AdminStates(StatesGroup):
waiting_for_user_search = State()
waiting_for_bulk_ban_list = State()
sending_user_message = State()
editing_user_balance = State()
extending_subscription = State()
@@ -141,6 +142,7 @@ class AdminStates(StatesGroup):
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
+61 -41
View File
@@ -2,7 +2,16 @@ from __future__ import annotations
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
Query,
Response,
Security,
status,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -233,6 +242,57 @@ async def list_faq_pages(
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: Optional[FaqStatusUpdateRequest] = Body(None),
language: str = Query("ru", min_length=2, max_length=10),
is_enabled: Optional[bool] = Query(None),
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
resolved_language = FaqService.normalize_language(
payload.language if payload and payload.language else language
)
enabled_status = payload.is_enabled if payload else is_enabled
if enabled_status is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Parameter 'is_enabled' is required"
)
setting = await FaqService.set_enabled(db, resolved_language, enabled_status)
return FaqStatusResponse(
requested_language=resolved_language,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
async def create_faq_page(
payload: FaqPageCreateRequest,
@@ -366,46 +426,6 @@ async def reorder_faq_pages(
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: FaqStatusUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
lang = FaqService.normalize_language(payload.language)
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
return FaqStatusResponse(
requested_language=lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.get("/service-rules", response_model=ServiceRulesResponse)
async def get_service_rules(
_: object = Security(require_api_token),
+1 -1
View File
@@ -71,7 +71,7 @@ services:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
interval: 60s
timeout: 10s
retries: 3
+1 -1
View File
@@ -76,7 +76,7 @@ services:
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
interval: 60s
timeout: 10s
retries: 3