Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe626f3442 | |||
| 50ad9a7a92 | |||
| a73ec96f5c | |||
| 1d072d4544 | |||
| 4997acd8bc | |||
| 94e202c596 | |||
| 9044060816 | |||
| 3be1a34725 | |||
| 41405b83ef | |||
| 2133355fbb | |||
| dd7e9ad43f | |||
| 460b8208d3 | |||
| 5c040a3b03 | |||
| 7dd7643a76 | |||
| 12a6ea1097 | |||
| fb71414096 | |||
| a814a95c34 | |||
| 3732359043 | |||
| 18d637c2fb | |||
| 48a7d2a112 | |||
| 4639020a8e | |||
| 27da3def3c | |||
| 77027adad3 | |||
| 76dd83ac58 | |||
| eb40143b76 | |||
| 1cda346174 | |||
| f534abdb6f | |||
| 5204a38528 | |||
| fcb9c1e3b7 | |||
| 5af41f8d4e | |||
| 592c9de77a | |||
| f67f4edc21 | |||
| a251835a79 | |||
| ba8a57e381 | |||
| 75f581924e | |||
| 10f2a8327c | |||
| 127a16b4d6 | |||
| f9ded2b857 | |||
| 622412992f | |||
| 5156c3481c | |||
| 1d3d4ac36e | |||
| 3449dc9d2c | |||
| acd01910ac | |||
| e16bcafce0 | |||
| b8e664aad8 | |||
| b365394ff4 | |||
| e53549d7cc | |||
| 743e99af85 | |||
| 9616fe8d44 | |||
| 4ae6361599 | |||
| 89c12e6ba9 | |||
| 5d6bc82d8a | |||
| a4f3fe5a49 | |||
| 3a58a3490a | |||
| e2408702ea | |||
| 3f1f27e5aa | |||
| bbd0ec2edd | |||
| eb22b19d98 | |||
| 45b9d44fd0 | |||
| a92003f0f5 | |||
| 4ac9387ef2 | |||
| a24b5e8145 | |||
| 7244e3978b | |||
| 41cabf5e0e | |||
| 99c7dc4b29 | |||
| 338bc03cd6 | |||
| 985260144e | |||
| b487925fbf | |||
| 07a89443b3 | |||
| 54ebb2d96d | |||
| 34a7cd1f6c | |||
| 455b8fc709 | |||
| 591b422302 | |||
| fc73c34a03 | |||
| cc1c6e3088 | |||
| 966f7124c4 | |||
| 1cebec5af4 | |||
| d31b5097ff | |||
| 21a8fe4025 | |||
| 6a4adcfe0c | |||
| c9acd46cd3 | |||
| 6018113a3d | |||
| fe0825957a | |||
| d0cef51e35 | |||
| 2aa0d11f4c | |||
| b8a1aa3f3c | |||
| 80e95bc77b | |||
| 87af0312cb | |||
| e988c9aa14 | |||
| cdee98f690 | |||
| 268c619ad6 | |||
| 352434ae35 | |||
| b1e39bae32 | |||
| 44a2c929c4 | |||
| 42a9865fac | |||
| c730863162 | |||
| b58db19127 | |||
| 2820c2d942 | |||
| 0be172c675 | |||
| b26fd77190 | |||
| 9317a1703a | |||
| f166279507 | |||
| 60216519af | |||
| ed83d31b22 | |||
| cf6e2955c7 | |||
| 818189e8e0 | |||
| 82e75f2170 | |||
| 1b265fd95b | |||
| 7a8905e0bc | |||
| 6d8ba6d30c |
@@ -12,6 +12,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
@@ -46,6 +51,14 @@ REMNAWAVE_PASSWORD=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
@@ -251,6 +264,8 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
|
||||
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
|
||||
@@ -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.3.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-$(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.3.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-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.3.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.1"
|
||||
VERSION="v2.3.4"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG VERSION="v2.3.1"
|
||||
ARG VERSION="v2.3.4"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
|
||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
@@ -235,6 +236,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
@@ -269,6 +275,14 @@ REMNAWAVE_PASSWORD=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
@@ -474,6 +488,8 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
|
||||
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
@@ -496,9 +512,10 @@ LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL= # Укажите домен вебхука
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -597,11 +614,13 @@ WEBHOOK_PATH=/webhook
|
||||
- 🚧 Потеря соелинения с апи Remnawave
|
||||
- 🗄️ **Бекапы/Восстановление бд**
|
||||
- 🗄️ Отправка бд файлов в отдельный чат/топики
|
||||
- ⚙️ Изменение параметров подписки юзером (Уменьшение/Увеличение трафика/серверов/устройств на подписке)
|
||||
|
||||
🗄️ **Бекапы/Восстановление**
|
||||
- Ручной запуск бекапа
|
||||
- Восстановление бд
|
||||
- Включение/Отключение автобекапов
|
||||
- Включение/Отключение автобекапов
|
||||
- Отправка копии бд файла в отдеьный чат/топик
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -892,6 +911,8 @@ bedolaga_bot/
|
||||
│ │ ├── ✅ subscription_utils.py # Проверка подписок
|
||||
│ │ ├── 📄 pagination.py # Пагинация
|
||||
│ │ ├── 📄 pricing_utils.py # Цены
|
||||
│ │ ├── 📄 message_patch.py
|
||||
│ │ ├── 📄 photo_message.py
|
||||
│ │ ├── ‼️ global_error.py # Обработка ошибок
|
||||
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
|
||||
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
|
||||
@@ -902,6 +923,7 @@ bedolaga_bot/
|
||||
│ │ ├── 📋 logging.py # Логирование
|
||||
│ │ ├── 🚧 maintenance.py # тех работы
|
||||
│ │ ├── 🔐 subscription_checker.py # тех работы
|
||||
│ │ ├── 🔐 channel_checker.py
|
||||
│ │ └── ⏱️ throttling.py # Ограничение запросов
|
||||
│ │
|
||||
│ ├── 🌐 localization/ # Локализация
|
||||
|
||||
+15
-4
@@ -74,20 +74,31 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
|
||||
else:
|
||||
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -14,6 +16,10 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
@@ -36,6 +42,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -252,6 +259,33 @@ class Settings(BaseSettings):
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def format_remnawave_user_description(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}"
|
||||
template_for_formatting = template.replace("@{username}", "{username}")
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name,
|
||||
"username": f"@{username_clean}" if username_clean else "",
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id)
|
||||
})
|
||||
|
||||
description = template_for_formatting.format_map(values)
|
||||
|
||||
if not username_clean:
|
||||
description = re.sub(r'@(?=\W|$)', '', description)
|
||||
description = re.sub(r'\(\s*\)', '', description)
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
|
||||
+139
-3
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,10 +53,37 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
@@ -79,4 +106,113 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
async def get_all_rules_versions(
|
||||
db: AsyncSession,
|
||||
language: str = "ru",
|
||||
limit: int = 10
|
||||
) -> list[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(ServiceRule.language == language)
|
||||
.order_by(ServiceRule.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def restore_rules_version(
|
||||
db: AsyncSession,
|
||||
rule_id: int,
|
||||
language: str = "ru"
|
||||
) -> Optional[ServiceRule]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.id == rule_id,
|
||||
ServiceRule.language == language
|
||||
)
|
||||
)
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
title=rule_to_restore.title,
|
||||
content=rule_to_restore.content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(restored_rule)
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
try:
|
||||
active_result = await db.execute(
|
||||
select(ServiceRule).where(ServiceRule.is_active == True)
|
||||
)
|
||||
active_rules = active_result.scalars().all()
|
||||
|
||||
all_result = await db.execute(select(ServiceRule))
|
||||
all_rules = all_result.scalars().all()
|
||||
|
||||
languages_stats = {}
|
||||
for rule in active_rules:
|
||||
lang = rule.language
|
||||
if lang not in languages_stats:
|
||||
languages_stats[lang] = {
|
||||
'active_count': 0,
|
||||
'last_updated': None,
|
||||
'content_length': 0
|
||||
}
|
||||
|
||||
languages_stats[lang]['active_count'] += 1
|
||||
languages_stats[lang]['content_length'] = len(rule.content)
|
||||
|
||||
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
|
||||
languages_stats[lang]['last_updated'] = rule.updated_at
|
||||
|
||||
return {
|
||||
'total_active': len(active_rules),
|
||||
'total_all_time': len(all_rules),
|
||||
'languages': languages_stats,
|
||||
'total_languages': len(languages_stats)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
|
||||
return {
|
||||
'total_active': 0,
|
||||
'total_all_time': 0,
|
||||
'languages': {},
|
||||
'total_languages': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -575,6 +575,10 @@ class BroadcastHistory(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
@@ -583,7 +587,6 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
|
||||
@@ -361,6 +361,49 @@ async def create_welcome_texts_table():
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
async def add_media_fields_to_broadcast_history():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
|
||||
|
||||
media_fields = {
|
||||
'has_media': 'BOOLEAN DEFAULT FALSE',
|
||||
'media_type': 'VARCHAR(20)',
|
||||
'media_file_id': 'VARCHAR(255)',
|
||||
'media_caption': 'TEXT'
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
for field_name, field_type in media_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f"Добавление поля {field_name} в таблицу broadcast_history")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT 0')
|
||||
elif db_type == 'postgresql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
elif db_type == 'mysql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
|
||||
alter_sql = f"ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}"
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f"✅ Поле {field_name} успешно добавлено")
|
||||
else:
|
||||
logger.info(f"Поле {field_name} уже существует в broadcast_history")
|
||||
|
||||
logger.info("✅ Все поля медиа в broadcast_history готовы")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -639,6 +682,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
|
||||
media_fields_added = await add_media_fields_to_broadcast_history()
|
||||
if media_fields_added:
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -689,6 +739,7 @@ async def run_universal_migration():
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
@@ -706,6 +757,7 @@ async def check_migration_status():
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
@@ -719,6 +771,14 @@ async def check_migration_status():
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
await check_column_exists('broadcast_history', 'media_file_id') and
|
||||
await check_column_exists('broadcast_history', 'media_caption')
|
||||
)
|
||||
status["broadcast_history_media_fields"] = media_fields_exist
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -737,6 +797,7 @@ async def check_migration_status():
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
@@ -755,12 +816,14 @@ async def check_migration_status():
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
welcome_texts_count = await conn.execute(text("SELECT COUNT(*) FROM welcome_texts"))
|
||||
broadcasts_count = await conn.execute(text("SELECT COUNT(*) FROM broadcast_history"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
welcome_count = welcome_texts_count.fetchone()[0]
|
||||
broadcast_count = broadcasts_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов")
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
|
||||
Vendored
+4
-2
@@ -33,6 +33,7 @@ class TelegramStarsService:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
@@ -46,7 +47,7 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
|
||||
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
f"для {chat_id}, курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
@@ -66,6 +67,7 @@ class TelegramStarsService:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
@@ -80,7 +82,7 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
|
||||
+143
-2
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -14,6 +15,8 @@ from app.keyboards.admin import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,10 +35,18 @@ async def show_admin_panel(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.get_system_statistics()
|
||||
users_online = stats.get("system", {}).get("users_online", 0)
|
||||
system_stats = stats.get("system", {})
|
||||
users_online = system_stats.get("users_online", 0)
|
||||
users_today = system_stats.get("users_last_day", 0)
|
||||
users_week = system_stats.get("users_last_week", 0)
|
||||
admin_text = admin_text.replace(
|
||||
"\n\nВыберите раздел для управления:",
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}\n\nВыберите раздел для управления:"
|
||||
(
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
|
||||
f"\n- 📅 Онлайн сегодня: {users_today}"
|
||||
f"\n- 🗓️ На этой неделе: {users_week}"
|
||||
"\n\nВыберите раздел для управления:"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
|
||||
@@ -137,6 +148,121 @@ async def show_system_submenu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if stats['total_active'] == 0:
|
||||
await message.reply(
|
||||
"ℹ️ <b>Правила уже очищены</b>\n\n"
|
||||
"В системе нет активных правил. Используются стандартные правила по умолчанию."
|
||||
)
|
||||
return
|
||||
|
||||
success = await clear_all_rules(db, db_user.language)
|
||||
|
||||
if success:
|
||||
clear_rules_cache()
|
||||
|
||||
await message.reply(
|
||||
f"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"• Очищено правил: {stats['total_active']}\n"
|
||||
f"• Язык: {db_user.language}\n"
|
||||
f"• Выполнил: {db_user.full_name}\n\n"
|
||||
f"Теперь используются стандартные правила по умолчанию."
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
|
||||
else:
|
||||
await message.reply(
|
||||
"⚠️ <b>Нет правил для очистки</b>\n\n"
|
||||
"Активные правила не найдены."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил командой: {e}")
|
||||
await message.reply(
|
||||
"❌ <b>Ошибка при очистке правил</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}\n"
|
||||
"Попробуйте через админ-панель или повторите позже."
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def rules_stats_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if 'error' in stats:
|
||||
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
|
||||
return
|
||||
|
||||
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
|
||||
text += f"📋 <b>Общая информация:</b>\n"
|
||||
text += f"• Активных правил: {stats['total_active']}\n"
|
||||
text += f"• Всего в истории: {stats['total_all_time']}\n"
|
||||
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
|
||||
|
||||
if stats['languages']:
|
||||
text += f"🌐 <b>По языкам:</b>\n"
|
||||
for lang, lang_stats in stats['languages'].items():
|
||||
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
|
||||
text += f"{lang_stats['content_length']} символов\n"
|
||||
if lang_stats['last_updated']:
|
||||
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||
else:
|
||||
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
|
||||
|
||||
await message.reply(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении статистики правил: {e}")
|
||||
await message.reply(
|
||||
f"❌ <b>Ошибка получения статистики</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_commands_help(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = """
|
||||
🔧 <b>Доступные админские команды:</b>
|
||||
|
||||
<b>📋 Управление правилами:</b>
|
||||
• <code>/clear_rules</code> - очистить все правила
|
||||
• <code>/rules_stats</code> - статистика правил
|
||||
|
||||
<b>ℹ️ Справка:</b>
|
||||
• <code>/admin_help</code> - это сообщение
|
||||
|
||||
<b>📱 Панель управления:</b>
|
||||
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
Все команды логируются и требуют админских прав.
|
||||
"""
|
||||
|
||||
await message.reply(help_text)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
@@ -167,3 +293,18 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
Command("clear_rules")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
rules_stats_command,
|
||||
Command("rules_stats")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
+498
-25
@@ -13,7 +13,8 @@ from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -23,6 +24,71 @@ from app.utils.decorators import admin_required, error_handler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
button_row = []
|
||||
if "balance" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
|
||||
|
||||
if "referrals" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
|
||||
|
||||
if "promocode" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
|
||||
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[:2])
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[2:])
|
||||
elif button_row:
|
||||
keyboard.append(button_row)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
@@ -107,13 +173,16 @@ async def show_messages_history(
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
import html
|
||||
message_preview = html.escape(message_preview)
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
@@ -121,7 +190,7 @@ async def show_messages_history(
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -260,15 +329,303 @@ async def process_broadcast_message(
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
await message.answer(
|
||||
"🖼️ <b>Добавление медиафайла</b>\n\n"
|
||||
"Вы можете добавить к сообщению фото, видео или документ.\n"
|
||||
"Или пропустить этот шаг.\n\n"
|
||||
"Выберите тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data == "skip_media":
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_media(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
expected_type = data.get('media_type')
|
||||
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
|
||||
if message.photo and expected_type == "photo":
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_type = "photo"
|
||||
elif message.video and expected_type == "video":
|
||||
media_file_id = message.video.file_id
|
||||
media_type = "video"
|
||||
elif message.document and expected_type == "document":
|
||||
media_file_id = message.document.file_id
|
||||
media_type = "document"
|
||||
else:
|
||||
await message.answer(
|
||||
f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
has_media=True,
|
||||
media_file_id=media_file_id,
|
||||
media_type=media_type,
|
||||
media_caption=message.caption
|
||||
)
|
||||
|
||||
await show_media_preview(message, db_user, state)
|
||||
|
||||
async def show_media_preview(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
|
||||
preview_text = f"🖼️ <b>Медиафайл добавлен</b>\n\n" \
|
||||
f"📎 <b>Тип:</b> {media_type}\n" \
|
||||
f"✅ Файл сохранен и готов к отправке\n\n" \
|
||||
f"Что делать дальше?"
|
||||
|
||||
# Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа
|
||||
# чтобы показать именно загруженное фото
|
||||
from app.utils.message_patch import _original_answer
|
||||
|
||||
if media_type == "photo" and media_file_id:
|
||||
# Показываем предпросмотр с загруженным фото
|
||||
await message.bot.send_photo(
|
||||
chat_id=message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для других типов медиа или если нет фото, используем обычное сообщение
|
||||
await _original_answer(message, preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
action = callback.data
|
||||
|
||||
if action == "confirm_media":
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
elif action == "replace_media":
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type', 'photo')
|
||||
await handle_media_selection(callback, db_user, state)
|
||||
elif action == "skip_media":
|
||||
await state.update_data(
|
||||
has_media=False,
|
||||
media_file_id=None,
|
||||
media_type=None,
|
||||
media_caption=None
|
||||
)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_change_media(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🖼️ <b>Изменение медиафайла</b>\n\n"
|
||||
"Выберите новый тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
has_media = data.get('has_media', False)
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
if 'selected_buttons' not in data:
|
||||
data['selected_buttons'] = []
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
button_type = callback.data.replace('btn_', '')
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
if button_type in selected_buttons:
|
||||
selected_buttons.remove(button_type)
|
||||
else:
|
||||
selected_buttons.append(button_type)
|
||||
|
||||
data['selected_buttons'] = selected_buttons
|
||||
await state.set_data(data)
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type_names = {
|
||||
"photo": "Фотография",
|
||||
"video": "Видео",
|
||||
"document": "Документ"
|
||||
}
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
buttons_info = ""
|
||||
if selected_buttons:
|
||||
buttons_list = []
|
||||
if "balance" in selected_buttons:
|
||||
buttons_list.append("💰 Пополнить баланс")
|
||||
if "referrals" in selected_buttons:
|
||||
buttons_list.append("🤝 Рефералы")
|
||||
if "promocode" in selected_buttons:
|
||||
buttons_list.append("🎫 Промокод")
|
||||
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
|
||||
|
||||
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
@@ -276,7 +633,9 @@ async def process_broadcast_message(
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
{message_text}{media_info}
|
||||
|
||||
{buttons_info}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
@@ -284,16 +643,82 @@ async def process_broadcast_message(
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
# Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение
|
||||
if has_media and media_type == "photo":
|
||||
media_file_id = data.get('media_file_id')
|
||||
if media_file_id:
|
||||
# Удаляем текущее сообщение и отправляем новое с фото
|
||||
await callback.message.delete()
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если нет file_id, используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для текстовых сообщений или других типов медиа используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -307,6 +732,11 @@ async def confirm_broadcast(
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
@@ -323,6 +753,10 @@ async def confirm_broadcast(
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
has_media=has_media,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
@@ -337,13 +771,42 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
@@ -359,6 +822,10 @@ async def confirm_broadcast(
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type}"
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
@@ -366,7 +833,7 @@ async def confirm_broadcast(
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
@@ -380,7 +847,7 @@ async def confirm_broadcast(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
@@ -413,7 +880,6 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -462,7 +928,6 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -557,4 +1022,12 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
|
||||
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
|
||||
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
|
||||
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
|
||||
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
|
||||
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -19,7 +20,6 @@ router = Router()
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -127,50 +127,57 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@router.callback_query(F.data.startswith("admin_mon_logs"))
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
page = 1
|
||||
if "_page_" in callback.data:
|
||||
page = int(callback.data.split("_page_")[1])
|
||||
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
if not all_logs:
|
||||
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
|
||||
keyboard = get_monitoring_logs_back_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
return
|
||||
|
||||
per_page = 8
|
||||
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
|
||||
|
||||
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
|
||||
|
||||
for log in paginated_logs.items:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
message = log['message']
|
||||
if len(message) > 45:
|
||||
message = message[:45] + "..."
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
total_success = sum(1 for log in all_logs if log['is_success'])
|
||||
total_failed = len(all_logs) - total_success
|
||||
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
|
||||
|
||||
text += f"📊 <b>Общая статистика:</b>\n"
|
||||
text += f"• Всего событий: {len(all_logs)}\n"
|
||||
text += f"• Успешных: {total_success}\n"
|
||||
text += f"• Ошибок: {total_failed}\n"
|
||||
text += f"• Успешность: {success_rate}%"
|
||||
|
||||
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
await callback.answer("❌ Ошибка получения логов", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@@ -178,12 +185,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
await callback.answer("ℹ️ Логи уже пусты")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
@@ -196,7 +203,6 @@ async def clear_logs_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
@@ -280,10 +286,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_monitoring_logs_back_keyboard():
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -309,5 +367,4 @@ async def monitoring_command(message: Message):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
+223
-42
@@ -8,7 +8,8 @@ from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +32,9 @@ async def show_rules_management(
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -48,16 +51,33 @@ async def view_current_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
is_valid, error_msg = validate_html_tags(current_rules)
|
||||
warning = ""
|
||||
if not is_valid:
|
||||
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -68,20 +88,33 @@ async def start_edit_rules(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
|
||||
text = (
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
|
||||
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при начале редактирования правил: {e}")
|
||||
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -98,19 +131,61 @@ async def process_rules_edit(
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
|
||||
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
|
||||
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
try:
|
||||
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
preview_text += f"Сохранить изменения?"
|
||||
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
|
||||
f"{new_rules[:500]}...\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Текст правил: {len(new_rules)} символов\n"
|
||||
f"Сохранить изменения?"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе превью правил: {e}")
|
||||
await message.answer(
|
||||
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
|
||||
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
|
||||
f"HTML теги проверены и корректны.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -128,6 +203,20 @@ async def save_rules(
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
|
||||
f"Правила не были сохранены из-за ошибок в HTML разметке.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
@@ -142,10 +231,14 @@ async def save_rules(
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
|
||||
"✓ Новые правила сохранены в базе данных\n"
|
||||
"✓ HTML теги проверены и корректны\n"
|
||||
"✓ Кеш правил очищен и обновлен\n"
|
||||
"✓ Правила будут показываться пользователям\n\n"
|
||||
f"📊 Размер текста: {len(new_rules)} символов",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
@@ -156,7 +249,90 @@ async def save_rules(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Ошибка при сохранении правил</b>\n\n"
|
||||
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Очистка правил сервиса</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
|
||||
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
|
||||
"Это действие нельзя отменить. Продолжить?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_clear_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await clear_all_rules(db, db_user.language)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
"✓ Все пользовательские правила удалены\n"
|
||||
"✓ Теперь используются стандартные правила\n"
|
||||
"✓ Кеш правил очищен\n\n"
|
||||
"Пользователи будут видеть правила по умолчанию.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил: {e}")
|
||||
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = get_html_help_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"ℹ️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -165,4 +341,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
|
||||
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
|
||||
|
||||
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
@@ -1644,7 +1644,12 @@ async def toggle_user_server(
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
active_internal_squads=current_squads,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2023,7 +2028,12 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
hwid_device_limit=devices,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2061,7 +2071,12 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
|
||||
+27
-15
@@ -149,19 +149,10 @@ async def show_payment_methods(
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
payment_text = """
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
@@ -171,6 +162,20 @@ async def show_payment_methods(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_payment_methods_unavailable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.answer(
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. "
|
||||
"Для пополнения баланса обратитесь в техподдержку.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -360,7 +365,7 @@ async def process_stars_payment_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -368,6 +373,7 @@ async def process_stars_payment_amount(
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
@@ -385,7 +391,7 @@ async def process_stars_payment_amount(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\n\n"
|
||||
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -395,7 +401,8 @@ async def process_stars_payment_amount(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -790,3 +797,8 @@ def register_handlers(dp: Dispatcher):
|
||||
check_cryptobot_payment_status,
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
)
|
||||
|
||||
@@ -42,9 +42,10 @@ async def show_main_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -119,7 +120,8 @@ async def handle_back_to_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,16 +90,45 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
|
||||
+157
-5
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +19,8 @@ from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -147,7 +150,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -448,7 +452,8 @@ async def complete_registration_from_callback(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -563,7 +568,8 @@ async def complete_registration_from_callback(
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -611,7 +617,8 @@ async def complete_registration(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -726,7 +733,8 @@ async def complete_registration(
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -844,6 +852,144 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery,
|
||||
bot: Bot,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
try:
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=query.from_user.id
|
||||
)
|
||||
|
||||
if chat_member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR]:
|
||||
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
|
||||
|
||||
await query.answer("✅ Спасибо за подписку", show_alert=True)
|
||||
|
||||
try:
|
||||
await query.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.start import get_main_menu_text
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_rules_keyboard
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=query.from_user.id,
|
||||
username=query.from_user.username,
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=f"Добро пожаловать, {user.full_name}!",
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text="У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в required_sub_channel_check: {e}")
|
||||
await query.answer("❌ Произошла ошибка!", show_alert=True)
|
||||
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
@@ -882,6 +1028,12 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
dp.callback_query.register(
|
||||
required_sub_channel_check,
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
+129
-41
@@ -229,23 +229,36 @@ async def get_current_devices_detailed(db_user: User) -> dict:
|
||||
return {"count": 0, "devices": []}
|
||||
|
||||
async def get_servers_display_names(squad_uuids: List[str]) -> str:
|
||||
"""
|
||||
Получает отображаемые названия серверов по их UUID
|
||||
"""
|
||||
if not squad_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
countries = await _get_available_countries()
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
server_names = []
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
break
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
for uuid in squad_uuids:
|
||||
server = await get_server_squad_by_uuid(db, uuid)
|
||||
if server:
|
||||
server_names.append(server.display_name)
|
||||
logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}")
|
||||
else:
|
||||
logger.warning(f"Сервер с UUID {uuid} не найден в БД")
|
||||
|
||||
if not server_names:
|
||||
countries = await _get_available_countries()
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}")
|
||||
break
|
||||
|
||||
if not server_names:
|
||||
if len(squad_uuids) == 1:
|
||||
return "🎯 Тестовый сервер"
|
||||
return f"{len(squad_uuids)} стран"
|
||||
|
||||
if len(server_names) > 6:
|
||||
@@ -257,6 +270,8 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения названий серверов: {e}")
|
||||
if len(squad_uuids) == 1:
|
||||
return "🎯 Тестовый сервер"
|
||||
return f"{len(squad_uuids)} стран"
|
||||
|
||||
async def get_current_devices_count(db_user: User) -> str:
|
||||
@@ -326,7 +341,6 @@ async def show_trial_offer(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
@@ -337,10 +351,27 @@ async def show_trial_offer(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
trial_server_name = "🎯 Тестовый сервер"
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
if settings.TRIAL_SQUAD_UUID:
|
||||
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
|
||||
if trial_server:
|
||||
trial_server_name = trial_server.display_name
|
||||
else:
|
||||
logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД")
|
||||
else:
|
||||
logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
devices=settings.TRIAL_DEVICE_LIMIT
|
||||
devices=settings.TRIAL_DEVICE_LIMIT,
|
||||
server_name=trial_server_name
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -626,7 +657,7 @@ async def apply_countries_changes(
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger.info(f"🔍 Применение изменений стран")
|
||||
logger.info(f"🔧 Применение изменений стран")
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -642,7 +673,7 @@ async def apply_countries_changes(
|
||||
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}")
|
||||
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
@@ -674,7 +705,7 @@ async def apply_countries_changes(
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
await callback.answer(
|
||||
f"⚠ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -686,7 +717,7 @@ async def apply_countries_changes(
|
||||
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
@@ -718,6 +749,15 @@ async def apply_countries_changes(
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
|
||||
|
||||
success_text = "✅ <b>Страны успешно обновлены!</b>\n\n"
|
||||
|
||||
if added_names:
|
||||
@@ -732,7 +772,7 @@ async def apply_countries_changes(
|
||||
success_text += "\n".join(f"• {name}" for name in removed_names)
|
||||
success_text += "\nℹ️ Повторное подключение будет платным\n"
|
||||
|
||||
success_text += f"\n🌍 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
success_text += f"\n🌐 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
@@ -744,7 +784,7 @@ async def apply_countries_changes(
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка применения изменений: {e}")
|
||||
logger.error(f"⚠️ Ошибка применения изменений: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -930,6 +970,15 @@ async def execute_change_devices(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "devices", current_devices, new_devices_count, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
|
||||
|
||||
if new_devices_count > current_devices:
|
||||
success_text = f"✅ Количество устройств увеличено!\n\n"
|
||||
success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n"
|
||||
@@ -1400,7 +1449,12 @@ async def confirm_add_devices(
|
||||
logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽")
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1511,7 +1565,12 @@ async def confirm_extend_subscription(
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⚠ Недостаточно средств на балансе", show_alert=True)
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1611,7 +1670,12 @@ async def confirm_reset_traffic(
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
|
||||
missing_kopeks = reset_price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -2346,7 +2410,12 @@ async def add_traffic(
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -2582,15 +2651,28 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
countries = await _get_available_countries()
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
if await _should_show_countries_management():
|
||||
countries = await _get_available_countries()
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
elif settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
texts.BUY_SUBSCRIPTION_START,
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -2784,11 +2866,10 @@ async def confirm_add_countries_to_subscription(
|
||||
removed_countries_names.append(country['name'])
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
f"❌ Недостаточно средств на балансе!\n\n"
|
||||
f"💰 Требуется: {texts.format_price(total_price)}\n"
|
||||
f"💳 У вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
@@ -3236,7 +3317,6 @@ async def show_device_connection_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает справку по повторному подключению устройств"""
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
@@ -3377,13 +3457,12 @@ async def confirm_switch_traffic(
|
||||
total_price_difference = price_difference_per_month * months_remaining
|
||||
|
||||
if db_user.balance_kopeks < total_price_difference:
|
||||
await callback.answer(
|
||||
f"⚠️ Недостаточно средств!\n"
|
||||
f"Требуется: {texts.format_price(total_price_difference)} "
|
||||
f"(за {months_remaining} мес)\n"
|
||||
f"У вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
show_alert=True
|
||||
missing_kopeks = total_price_difference - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
|
||||
@@ -3455,6 +3534,15 @@ async def execute_switch_traffic(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
|
||||
|
||||
if new_traffic_gb > current_traffic:
|
||||
success_text = f"✅ Лимит трафика увеличен!\n\n"
|
||||
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
|
||||
|
||||
+246
-15
@@ -8,20 +8,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Юзеры и Подписки", callback_data="admin_submenu_users"),
|
||||
InlineKeyboardButton(text="💰 Промокоды и статистика", callback_data="admin_submenu_promo")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📨 Коммуникации", callback_data="admin_submenu_communications"),
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🛠️ Системные функции", callback_data="admin_submenu_system")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
@@ -548,7 +540,7 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
@@ -570,6 +562,170 @@ def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_navigation_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
has_logs: bool = True
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page_info"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
management_row = []
|
||||
|
||||
if has_logs:
|
||||
management_row.extend([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
])
|
||||
else:
|
||||
management_row.append(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs")
|
||||
)
|
||||
|
||||
keyboard.append(management_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить этот лог",
|
||||
callback_data=f"admin_mon_delete_log_{log_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку логов",
|
||||
callback_data=f"admin_mon_logs_page_{current_page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_status_keyboard(
|
||||
is_running: bool,
|
||||
last_check_ago_minutes: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
control_row = []
|
||||
if is_running:
|
||||
control_row.extend([
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"),
|
||||
InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart")
|
||||
])
|
||||
else:
|
||||
control_row.append(
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start")
|
||||
)
|
||||
|
||||
keyboard.append(control_row)
|
||||
|
||||
monitoring_row = []
|
||||
|
||||
if not is_running or last_check_ago_minutes > 10:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⚡ Срочная проверка",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
else:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить сейчас",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(monitoring_row)
|
||||
|
||||
info_row = [
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
]
|
||||
keyboard.append(info_row)
|
||||
|
||||
test_row = [
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications")
|
||||
]
|
||||
keyboard.append(test_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"),
|
||||
InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"),
|
||||
InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_log_type_filter_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"),
|
||||
InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"),
|
||||
InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -756,3 +912,78 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"),
|
||||
InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"),
|
||||
InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"),
|
||||
InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
]
|
||||
]
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
+73
-16
@@ -21,6 +21,22 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(channel_link: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подписаться", url=channel_link
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался", callback_data="sub_channel_check"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -39,7 +55,8 @@ def get_main_menu_keyboard(
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -51,16 +68,47 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
callback_data="subscription_connect"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
@@ -410,7 +458,16 @@ def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="topup_stars"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -435,14 +492,6 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="topup_stars"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🛠️ Через поддержку",
|
||||
@@ -450,6 +499,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
)
|
||||
])
|
||||
|
||||
if len(keyboard) == 1:
|
||||
keyboard.insert(0, [
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Способы оплаты временно недоступны",
|
||||
callback_data="payment_methods_unavailable"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
@@ -155,7 +155,7 @@ class RussianTexts(Texts):
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
🌍 <b>Сервер:</b> {server_name}
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
+57
-23
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
@@ -8,11 +9,31 @@ from aiogram.fsm.context import FSMContext
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _refresh_remnawave_description(
|
||||
remnawave_uuid: str,
|
||||
description: str,
|
||||
telegram_id: int
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(
|
||||
f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}"
|
||||
)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
@@ -21,7 +42,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
@@ -42,24 +63,23 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
is_channel_check = (isinstance(event, CallbackQuery)
|
||||
and event.data == "sub_channel_check")
|
||||
|
||||
is_start_command = (isinstance(event, Message)
|
||||
and event.text
|
||||
and event.text.startswith('/start'))
|
||||
|
||||
if is_reg_process or is_channel_check or is_start_command:
|
||||
if is_start_command:
|
||||
logger.info(f"🚀 Пропускаем команду /start от пользователя {user.id}")
|
||||
elif is_channel_check:
|
||||
logger.info(f"🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала")
|
||||
else:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
@@ -135,27 +155,41 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if db_user.username != user.username:
|
||||
old_username = db_user.username
|
||||
db_user.username = user.username
|
||||
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=db_user.full_name,
|
||||
username=db_user.username,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
asyncio.create_task(
|
||||
_refresh_remnawave_description(
|
||||
remnawave_uuid=db_user.remnawave_uuid,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
@@ -170,4 +204,4 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
raise
|
||||
@@ -0,0 +1,125 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = (
|
||||
ChatMemberStatus.LEFT,
|
||||
ChatMemberStatus.KICKED,
|
||||
ChatMemberStatus.RESTRICTED
|
||||
)
|
||||
self.GOOD_MEMBER_STATUS = (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
)
|
||||
logger.info("🔧 ChannelCheckerMiddleware инициализирован")
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id is None:
|
||||
logger.debug("❌ telegram_id не найден, пропускаем")
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_reg_process:
|
||||
logger.debug("✅ Событие разрешено (процесс регистрации), пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
bot: Bot = data["bot"]
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
|
||||
if not channel_id:
|
||||
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
is_required = settings.CHANNEL_IS_REQUIRED_SUB
|
||||
|
||||
if not is_required:
|
||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
channel_link = settings.CHANNEL_LINK
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
|
||||
if member.status in self.GOOD_MEMBER_STATUS:
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
||||
elif "user not found" in str(e).lower():
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link)
|
||||
text = f"""🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, Update) and event.message:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при отправке сообщения о подписке: {e}")
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
@@ -707,4 +707,97 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
update_type: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
price_paid: int = 0
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
|
||||
message += f"""
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
return False
|
||||
|
||||
async def _format_servers_detailed(self, server_uuids: List[str]) -> str:
|
||||
if not server_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(server_uuids)
|
||||
|
||||
if servers_names and servers_names != "Нет серверов":
|
||||
return f"{len(server_uuids)} серверов ({servers_names})"
|
||||
else:
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения названий серверов для уведомления: {e}")
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
def _format_update_value(self, value: Any, update_type: str) -> str:
|
||||
if update_type == "traffic":
|
||||
if value == 0:
|
||||
return "♾ Безлимитный"
|
||||
return f"{value} ГБ"
|
||||
elif update_type == "devices":
|
||||
return f"{value} устройств"
|
||||
elif update_type == "servers":
|
||||
if isinstance(value, list):
|
||||
return f"{len(value)} серверов"
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
+163
-52
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, Base
|
||||
CryptoBotPayment, WelcomeText, Base
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.0"
|
||||
version: str = "1.1"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -60,16 +60,32 @@ class BackupService:
|
||||
self._auto_backup_task = None
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models = [
|
||||
User, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, ServiceRule, SystemSetting,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage,
|
||||
YooKassaPayment, CryptoBotPayment
|
||||
self.backup_models_ordered = [
|
||||
ServiceRule,
|
||||
SystemSetting,
|
||||
Squad,
|
||||
PromoCode,
|
||||
ServerSquad,
|
||||
|
||||
User,
|
||||
|
||||
WelcomeText,
|
||||
Subscription,
|
||||
Transaction,
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionConversion,
|
||||
BroadcastHistory,
|
||||
UserMessage,
|
||||
|
||||
SentNotification,
|
||||
SubscriptionServer,
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models.append(MonitoringLog)
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
@@ -82,6 +98,53 @@ class BackupService:
|
||||
backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups")
|
||||
)
|
||||
|
||||
def _parse_backup_time(self) -> Tuple[int, int]:
|
||||
time_str = (self._settings.backup_time or "").strip()
|
||||
|
||||
try:
|
||||
parts = time_str.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError("Invalid time format")
|
||||
|
||||
hours, minutes = map(int, parts)
|
||||
|
||||
if not (0 <= hours < 24 and 0 <= minutes < 60):
|
||||
raise ValueError("Hours or minutes out of range")
|
||||
|
||||
return hours, minutes
|
||||
|
||||
except ValueError:
|
||||
default_hours, default_minutes = 3, 0
|
||||
logger.warning(
|
||||
"Некорректное значение BACKUP_TIME='%s'. Используется значение по умолчанию 03:00.",
|
||||
self._settings.backup_time
|
||||
)
|
||||
self._settings.backup_time = "03:00"
|
||||
return default_hours, default_minutes
|
||||
|
||||
def _calculate_next_backup_datetime(self, reference: Optional[datetime] = None) -> datetime:
|
||||
reference = reference or datetime.now()
|
||||
hours, minutes = self._parse_backup_time()
|
||||
|
||||
next_run = reference.replace(hour=hours, minute=minutes, second=0, microsecond=0)
|
||||
if next_run <= reference:
|
||||
next_run += timedelta(days=1)
|
||||
|
||||
return next_run
|
||||
|
||||
def _get_backup_interval(self) -> timedelta:
|
||||
hours = self._settings.backup_interval_hours
|
||||
|
||||
if hours <= 0:
|
||||
logger.warning(
|
||||
"Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.",
|
||||
hours
|
||||
)
|
||||
hours = 24
|
||||
self._settings.backup_interval_hours = hours
|
||||
|
||||
return timedelta(hours=hours)
|
||||
|
||||
async def create_backup(
|
||||
self,
|
||||
created_by: Optional[int] = None,
|
||||
@@ -89,12 +152,12 @@ class BackupService:
|
||||
include_logs: bool = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
try:
|
||||
logger.info("🔄 Начинаем создание бекапа...")
|
||||
logger.info("📄 Начинаем создание бекапа...")
|
||||
|
||||
if include_logs is None:
|
||||
include_logs = self._settings.include_logs
|
||||
|
||||
models_to_backup = self.backup_models.copy()
|
||||
models_to_backup = self.backup_models_ordered.copy()
|
||||
if not include_logs and MonitoringLog in models_to_backup:
|
||||
models_to_backup.remove(MonitoringLog)
|
||||
elif include_logs and MonitoringLog not in models_to_backup:
|
||||
@@ -109,7 +172,16 @@ class BackupService:
|
||||
table_name = model.__tablename__
|
||||
logger.info(f"📊 Экспортируем таблицу: {table_name}")
|
||||
|
||||
result = await db.execute(select(model))
|
||||
query = select(model)
|
||||
|
||||
if model == User:
|
||||
query = query.options(selectinload(User.subscription))
|
||||
elif model == Subscription:
|
||||
query = query.options(selectinload(Subscription.user))
|
||||
elif model == Transaction:
|
||||
query = query.options(selectinload(Transaction.user))
|
||||
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
|
||||
table_data = []
|
||||
@@ -118,8 +190,12 @@ class BackupService:
|
||||
for column in model.__table__.columns:
|
||||
value = getattr(record, column.name)
|
||||
|
||||
if isinstance(value, datetime):
|
||||
if value is None:
|
||||
record_dict[column.name] = None
|
||||
elif isinstance(value, datetime):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif isinstance(value, (list, dict)):
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
elif hasattr(value, '__dict__'):
|
||||
record_dict[column.name] = str(value)
|
||||
else:
|
||||
@@ -218,7 +294,7 @@ class BackupService:
|
||||
clear_existing: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем восстановление из {backup_file_path}")
|
||||
logger.info(f"📄 Начинаем восстановление из {backup_file_path}")
|
||||
|
||||
backup_path = Path(backup_file_path)
|
||||
if not backup_path.exists():
|
||||
@@ -252,21 +328,25 @@ class BackupService:
|
||||
logger.warning("🗑️ Очищаем существующие данные...")
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
for table_name, records in backup_data.items():
|
||||
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
|
||||
|
||||
restore_order = []
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
if table_name in backup_data and backup_data[table_name]:
|
||||
restore_order.append(table_name)
|
||||
|
||||
for table_name in restore_order:
|
||||
records = backup_data[table_name]
|
||||
if not records:
|
||||
continue
|
||||
|
||||
model = None
|
||||
for m in self.backup_models:
|
||||
if m.__tablename__ == table_name:
|
||||
model = m
|
||||
break
|
||||
|
||||
model = models_by_table.get(table_name)
|
||||
if not model:
|
||||
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
|
||||
continue
|
||||
|
||||
logger.info(f"📥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
@@ -278,9 +358,11 @@ class BackupService:
|
||||
|
||||
column = getattr(model.__table__.columns, key, None)
|
||||
if column is None:
|
||||
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
|
||||
continue
|
||||
|
||||
column_type_str = str(column.type).upper()
|
||||
|
||||
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
if 'T' in value:
|
||||
@@ -292,7 +374,7 @@ class BackupService:
|
||||
processed_data[key] = datetime.utcnow()
|
||||
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
|
||||
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif ('INTEGER' in column_type_str or 'INT' in column_type_str) and isinstance(value, str):
|
||||
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = int(value)
|
||||
except ValueError:
|
||||
@@ -302,15 +384,19 @@ class BackupService:
|
||||
processed_data[key] = float(value)
|
||||
except ValueError:
|
||||
processed_data[key] = 0.0
|
||||
elif 'JSON' in column_type_str and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = json_lib.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
elif 'JSON' in column_type_str:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
processed_data[key] = json_lib.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
processed_data[key] = value
|
||||
elif isinstance(value, (list, dict)):
|
||||
processed_data[key] = value
|
||||
else:
|
||||
processed_data[key] = None
|
||||
else:
|
||||
processed_data[key] = value
|
||||
|
||||
# Проверяем существует ли запись с таким ID
|
||||
primary_key_col = None
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
@@ -318,7 +404,6 @@ class BackupService:
|
||||
break
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
# Проверяем существование записи
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
@@ -326,18 +411,15 @@ class BackupService:
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Обновляем существующую запись
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col: # Не обновляем primary key
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
# Создаем новую запись
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
# Если нет primary key или он не в данных, просто добавляем
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
@@ -345,6 +427,7 @@ class BackupService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
continue
|
||||
|
||||
restored_tables += 1
|
||||
@@ -384,11 +467,12 @@ class BackupService:
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications", "broadcast_history",
|
||||
"subscription_conversions", "referral_earnings", "promocode_uses",
|
||||
"transactions", "yookassa_payments", "cryptobot_payments",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"service_rules", "system_settings", "monitoring_logs", "user_messages"
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses", "transactions",
|
||||
"yookassa_payments", "cryptobot_payments", "welcome_texts",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"squads", "service_rules", "system_settings", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -424,7 +508,8 @@ class BackupService:
|
||||
"file_size_bytes": file_stats.st_size,
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": metadata.get("created_by"),
|
||||
"database_type": metadata.get("database_type", "unknown")
|
||||
"database_type": metadata.get("database_type", "unknown"),
|
||||
"version": metadata.get("version", "1.0")
|
||||
}
|
||||
|
||||
backups.append(backup_info)
|
||||
@@ -443,6 +528,7 @@ class BackupService:
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": None,
|
||||
"database_type": "unknown",
|
||||
"version": "unknown",
|
||||
"error": f"Ошибка чтения: {str(e)}"
|
||||
})
|
||||
|
||||
@@ -509,34 +595,59 @@ class BackupService:
|
||||
async def start_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
|
||||
|
||||
if self._settings.auto_backup_enabled:
|
||||
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop())
|
||||
logger.info(f"🔄 Автобекапы включены, интервал: {self._settings.backup_interval_hours}ч")
|
||||
next_run = self._calculate_next_backup_datetime()
|
||||
interval = self._get_backup_interval()
|
||||
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop(next_run))
|
||||
logger.info(
|
||||
"📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s",
|
||||
interval.total_seconds() / 3600,
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S")
|
||||
)
|
||||
|
||||
async def stop_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
logger.info("⏹️ Автобекапы остановлены")
|
||||
logger.info("ℹ️ Автобекапы остановлены")
|
||||
|
||||
async def _auto_backup_loop(self, next_run: Optional[datetime] = None):
|
||||
next_run = next_run or self._calculate_next_backup_datetime()
|
||||
interval = self._get_backup_interval()
|
||||
|
||||
async def _auto_backup_loop(self):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self._settings.backup_interval_hours * 3600)
|
||||
|
||||
logger.info("🔄 Запуск автоматического бекапа...")
|
||||
now = datetime.now()
|
||||
delay = (next_run - now).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
logger.info(
|
||||
"⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)",
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
delay / 3600
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.info(
|
||||
"⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно",
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S")
|
||||
)
|
||||
|
||||
logger.info("📄 Запуск автоматического бекапа...")
|
||||
success, message, _ = await self.create_backup()
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Автобекап завершен: {message}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка автобекапа: {message}")
|
||||
|
||||
|
||||
next_run = next_run + interval
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле автобекапов: {e}")
|
||||
await asyncio.sleep(3600)
|
||||
next_run = datetime.now() + interval
|
||||
|
||||
async def _send_backup_notification(
|
||||
self,
|
||||
@@ -551,7 +662,7 @@ class BackupService:
|
||||
icons = {
|
||||
"success": "✅",
|
||||
"error": "❌",
|
||||
"restore_success": "📥",
|
||||
"restore_success": "🔥",
|
||||
"restore_error": "❌"
|
||||
}
|
||||
|
||||
|
||||
@@ -158,8 +158,13 @@ class MonitoringService:
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -693,7 +698,9 @@ class MonitoringService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
event_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
@@ -703,7 +710,11 @@ class MonitoringService:
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
if page > 1 or per_page != 20:
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
else:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
@@ -723,25 +734,54 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
return count or 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
if days == 0:
|
||||
result = await db.execute(delete(MonitoringLog))
|
||||
else:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
if days == 0:
|
||||
logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)")
|
||||
else:
|
||||
logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+144
-14
@@ -4,7 +4,7 @@ import hmac
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -12,10 +12,16 @@ from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,6 +124,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -126,7 +160,8 @@ class PaymentService:
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
@@ -309,6 +344,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -316,9 +379,12 @@ class PaymentService:
|
||||
f"🦊 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
@@ -382,20 +448,55 @@ class PaymentService:
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
message = (
|
||||
f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
@@ -641,6 +742,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -649,7 +778,8 @@ class PaymentService:
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -779,6 +779,11 @@ class RemnaWaveService:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
stats["updated"] += 1
|
||||
@@ -793,7 +798,11 @@ class RemnaWaveService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -82,7 +87,11 @@ class SubscriptionService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -135,8 +144,13 @@ class SubscriptionService:
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.transaction import (
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -218,7 +219,40 @@ class TributeService:
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
]
|
||||
)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
@@ -226,12 +260,7 @@ class TributeService:
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
|
||||
+137
-85
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
@@ -12,8 +11,10 @@ from app.database.crud.user import (
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -247,39 +248,89 @@ class UserService:
|
||||
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import UserMessage
|
||||
from sqlalchemy import update
|
||||
sent_notifications_result = await db.execute(
|
||||
select(SentNotification).where(SentNotification.user_id == user_id)
|
||||
)
|
||||
sent_notifications = sent_notifications_result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
if sent_notifications:
|
||||
logger.info(f"🔄 Удаляем {len(sent_notifications)} уведомлений")
|
||||
await db.execute(
|
||||
delete(SentNotification).where(SentNotification.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления уведомлений: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
subscription_servers_result = await db.execute(
|
||||
select(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
subscription_servers = subscription_servers_result.scalars().all()
|
||||
|
||||
if subscription_servers:
|
||||
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления связей подписка-сервер: {e}")
|
||||
|
||||
try:
|
||||
user_messages_result = await db.execute(
|
||||
update(UserMessage)
|
||||
.where(UserMessage.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
|
||||
if user_messages_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
|
||||
|
||||
|
||||
try:
|
||||
from app.database.models import PromoCode
|
||||
from sqlalchemy import update
|
||||
|
||||
result = await db.execute(
|
||||
promocodes_result = await db.execute(
|
||||
update(PromoCode)
|
||||
.where(PromoCode.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
|
||||
if promocodes_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {promocodes_result.rowcount} промокодов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления промокодов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
welcome_texts_result = await db.execute(
|
||||
update(WelcomeText)
|
||||
.where(WelcomeText.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if welcome_texts_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления приветственных текстов: {e}")
|
||||
|
||||
try:
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
@@ -287,18 +338,20 @@ class UserService:
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
from app.database.models import CryptoBotPayment
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
cryptobot_result = await db.execute(
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
@@ -306,14 +359,19 @@ class UserService:
|
||||
|
||||
if cryptobot_payments:
|
||||
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
|
||||
await db.execute(
|
||||
update(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ CryptoBot платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
@@ -326,49 +384,55 @@ class UserService:
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
promocode_uses_result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
promocode_uses = promocode_uses_result.scalars().all()
|
||||
|
||||
if promocode_uses:
|
||||
logger.info(f"🔄 Удаляем {len(promocode_uses)} использований промокодов")
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
referral_earnings_result = await db.execute(
|
||||
select(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
referral_earnings = referral_earnings_result.scalars().all()
|
||||
|
||||
if referral_earnings:
|
||||
logger.info(f"🔄 Удаляем {len(referral_earnings)} реферальных доходов")
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
referral_records_result = await db.execute(
|
||||
select(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
referral_records = referral_records_result.scalars().all()
|
||||
|
||||
if referral_records:
|
||||
logger.info(f"🔄 Удаляем {len(referral_records)} записей о рефералах")
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
logger.error(f"❌ Ошибка удаления записей о рефералах: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import SubscriptionConversion
|
||||
conversions_result = await db.execute(
|
||||
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
@@ -380,46 +444,34 @@ class UserService:
|
||||
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Записи конверсий удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
|
||||
try:
|
||||
broadcast_history_result = await db.execute(
|
||||
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
broadcast_history = broadcast_history_result.scalars().all()
|
||||
|
||||
if broadcast_history:
|
||||
logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок")
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
from app.database.models import Subscription
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
logger.info(f"🔄 Удаляем подписку {user.subscription.id}")
|
||||
await db.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
logger.error(f"❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(User).where(User.id == user_id)
|
||||
|
||||
@@ -43,6 +43,7 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
@@ -67,6 +68,7 @@ class AdminStates(StatesGroup):
|
||||
creating_server_country = State()
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from app.states import RegistrationStates
|
||||
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
if current_state in registration_states:
|
||||
return True
|
||||
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and event.data in registration_callbacks):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -22,9 +22,14 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
|
||||
async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if self.photo:
|
||||
media = self.photo[-1].file_id
|
||||
if is_qr_message(self) and LOGO_PATH.exists():
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
elif is_qr_message(self) and LOGO_PATH.exists():
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import List, Dict, Tuple
|
||||
from app.config import settings
|
||||
|
||||
def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"""
|
||||
Возвращает список доступных способов оплаты с их настройками
|
||||
"""
|
||||
methods = []
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
methods.append({
|
||||
"id": "stars",
|
||||
"name": "Telegram Stars",
|
||||
"icon": "⭐",
|
||||
"description": "быстро и удобно",
|
||||
"callback": "topup_stars"
|
||||
})
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
methods.append({
|
||||
"id": "yookassa",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через YooKassa",
|
||||
"callback": "topup_yookassa"
|
||||
})
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append({
|
||||
"id": "tribute",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Tribute",
|
||||
"callback": "topup_tribute"
|
||||
})
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append({
|
||||
"id": "cryptobot",
|
||||
"name": "Криптовалюта",
|
||||
"icon": "🪙",
|
||||
"description": "через CryptoBot",
|
||||
"callback": "topup_cryptobot"
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
"id": "support",
|
||||
"name": "Через поддержку",
|
||||
"icon": "🛠️",
|
||||
"description": "другие способы",
|
||||
"callback": "topup_support"
|
||||
})
|
||||
|
||||
return methods
|
||||
|
||||
def get_payment_methods_text() -> str:
|
||||
"""
|
||||
Генерирует текст с описанием доступных способов оплаты
|
||||
"""
|
||||
methods = get_available_payment_methods()
|
||||
|
||||
if len(methods) <= 1: # Только поддержка
|
||||
return """💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент автоматические способы оплаты временно недоступны.
|
||||
Обратитесь в техподдержку для пополнения баланса.
|
||||
|
||||
Выберите способ пополнения:"""
|
||||
|
||||
text = "💳 <b>Способы пополнения баланса</b>\n\n"
|
||||
text += "Выберите удобный для вас способ оплаты:\n\n"
|
||||
|
||||
for method in methods:
|
||||
text += f"{method['icon']} <b>{method['name']}</b> - {method['description']}\n"
|
||||
|
||||
text += "\nВыберите способ пополнения:"
|
||||
|
||||
return text
|
||||
|
||||
def is_payment_method_available(method_id: str) -> bool:
|
||||
"""
|
||||
Проверяет, доступен ли конкретный способ оплаты
|
||||
"""
|
||||
if method_id == "stars":
|
||||
return settings.TELEGRAM_STARS_ENABLED
|
||||
elif method_id == "yookassa":
|
||||
return settings.is_yookassa_enabled()
|
||||
elif method_id == "tribute":
|
||||
return settings.TRIBUTE_ENABLED
|
||||
elif method_id == "cryptobot":
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_payment_method_status() -> Dict[str, bool]:
|
||||
"""
|
||||
Возвращает статус всех способов оплаты
|
||||
"""
|
||||
return {
|
||||
"stars": settings.TELEGRAM_STARS_ENABLED,
|
||||
"yookassa": settings.is_yookassa_enabled(),
|
||||
"tribute": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"support": True
|
||||
}
|
||||
|
||||
def get_enabled_payment_methods_count() -> int:
|
||||
"""
|
||||
Возвращает количество включенных способов оплаты (не считая поддержку)
|
||||
"""
|
||||
count = 0
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
count += 1
|
||||
if settings.is_yookassa_enabled():
|
||||
count += 1
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
count += 1
|
||||
return count
|
||||
@@ -7,7 +7,12 @@ from .message_patch import LOGO_PATH, is_qr_message
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
if message.photo and not is_qr_message(message):
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and not is_qr_message(message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Только если режим логотипа выключен, используем фото из сообщения
|
||||
elif message.photo:
|
||||
return message.photo[-1].file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
+86
-18
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
@@ -14,6 +14,11 @@ ALLOWED_HTML_TAGS = {
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
SELF_CLOSING_TAGS = {
|
||||
'br', 'hr', 'img'
|
||||
}
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
@@ -158,7 +163,8 @@ def validate_referral_code(code: str) -> bool:
|
||||
|
||||
return validate_promocode(code)
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
|
||||
def validate_html_tags(text: str) -> Tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
@@ -168,21 +174,34 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS:
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS and tag_name_lower not in SELF_CLOSING_TAGS:
|
||||
return False, f"Неподдерживаемый тег: <{tag_name}>"
|
||||
|
||||
return validate_html_structure(text)
|
||||
|
||||
|
||||
def validate_html_structure(text: str) -> Tuple[bool, str]:
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?/?>'
|
||||
|
||||
matches = re.finditer(tag_pattern, text)
|
||||
tag_stack = []
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
for match in matches:
|
||||
full_tag = match.group(0)
|
||||
is_closing = bool(match.group(1))
|
||||
tag_name = match.group(2).lower()
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name_lower)
|
||||
else:
|
||||
if full_tag.endswith('/>') or tag_name in SELF_CLOSING_TAGS:
|
||||
continue
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name)
|
||||
else:
|
||||
if not tag_stack:
|
||||
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
|
||||
|
||||
last_tag = tag_stack.pop()
|
||||
if last_tag != tag_name_lower:
|
||||
if last_tag != tag_name:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
@@ -191,16 +210,65 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def fix_html_tags(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
fixes = [
|
||||
(r'<a href=([^"\s>]+)>', r'<a href="\1">'),
|
||||
(r'<(br|hr|img[^>]*?)>', r'<\1 />'),
|
||||
(r'<<([^>]+)>>', r'<\1>'),
|
||||
(r'<\s+([^>]+)\s+>', r'<\1>'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in fixes:
|
||||
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_html_help_text() -> str:
|
||||
return """<b>Поддерживаемые HTML теги:</b>
|
||||
|
||||
- <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
- <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
- <code><u>подчеркнутый</u></code>
|
||||
- <code><s>зачеркнутый</s></code>
|
||||
- <code><code>моноширинный</code></code>
|
||||
- <code><pre>блок кода</pre></code>
|
||||
- <code><a href="url">ссылка</a></code>
|
||||
- <code><blockquote>цитата</blockquote></code>
|
||||
• <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
• <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
• <code><u>подчеркнутый</u></code>
|
||||
• <code><s>зачеркнутый</s></code>
|
||||
• <code><code>моноширинный</code></code>
|
||||
• <code><pre>блок кода</pre></code>
|
||||
• <code><a href="url">ссылка</a></code>
|
||||
• <code><blockquote>цитата</blockquote></code>
|
||||
|
||||
<b>Неподдерживаемые теги:</b> <br>, <p>, <div>, <span>, <spoiler> и другие"""
|
||||
<b>⚠️ Важные правила:</b>
|
||||
• Каждый открывающий тег должен быть закрыт
|
||||
• Теги должны быть правильно вложены
|
||||
• Атрибуты ссылок берите в кавычки
|
||||
|
||||
<b>❌ Неправильно:</b>
|
||||
<code><b>жирный <i>курсив</b></i></code>
|
||||
<code><a href=google.com>ссылка</a></code>
|
||||
|
||||
<b>✅ Правильно:</b>
|
||||
<code><b>жирный <i>курсив</i></b></code>
|
||||
<code><a href="https://google.com">ссылка</a></code>"""
|
||||
|
||||
|
||||
def validate_rules_content(text: str) -> Tuple[bool, str, Optional[str]]:
|
||||
if not text or not text.strip():
|
||||
return False, "Текст правил не может быть пустым", None
|
||||
|
||||
if len(text) > 4000:
|
||||
return False, f"Текст слишком длинный: {len(text)} символов (максимум 4000)", None
|
||||
|
||||
is_valid_html, html_error = validate_html_tags(text)
|
||||
if not is_valid_html:
|
||||
fixed_text = fix_html_tags(text)
|
||||
fixed_is_valid, _ = validate_html_tags(fixed_text)
|
||||
|
||||
if fixed_is_valid and fixed_text != text:
|
||||
return False, html_error, fixed_text
|
||||
else:
|
||||
return False, html_error, None
|
||||
|
||||
return True, "", None
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 716 KiB |
Reference in New Issue
Block a user