Compare commits
45 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 | |||
| 99c7dc4b29 |
+3
-1
@@ -14,7 +14,7 @@ ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топ
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_SUB_REQUIRED=false # Обязательна ли подписка на канал
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
@@ -264,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.3-$(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.3-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.3-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.3"
|
||||
VERSION="v2.3.4"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.3-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.3"
|
||||
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/ # Локализация
|
||||
|
||||
+14
-6
@@ -5,7 +5,6 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
@@ -75,6 +74,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
@@ -82,17 +82,25 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
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())
|
||||
dp.message.middleware(ChannelCheckerMiddleware())
|
||||
dp.callback_query.middleware(ChannelCheckerMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
|
||||
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,
|
||||
|
||||
@@ -421,17 +421,31 @@ async def show_media_preview(
|
||||
):
|
||||
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"Что делать дальше?"
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
# Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа
|
||||
# чтобы показать именно загруженное фото
|
||||
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
|
||||
@@ -642,11 +656,34 @@ async def confirm_button_selection(
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
# Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение
|
||||
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()
|
||||
|
||||
|
||||
|
||||
+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"
|
||||
)
|
||||
|
||||
+130
-9
@@ -19,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__)
|
||||
@@ -858,15 +860,134 @@ async def required_sub_channel_check(
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
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]:
|
||||
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
|
||||
await query.answer("✅ Спасибо за подписку", show_alert=True)
|
||||
await query.message.delete()
|
||||
await cmd_start(query.message, state, db, db_user)
|
||||
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):
|
||||
|
||||
@@ -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(
|
||||
@@ -1418,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:
|
||||
@@ -1529,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:
|
||||
@@ -1629,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:
|
||||
@@ -2364,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:
|
||||
@@ -2600,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
|
||||
@@ -2802,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()
|
||||
@@ -3254,7 +3317,6 @@ async def show_device_connection_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает справку по повторному подключению устройств"""
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
@@ -3395,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)}"
|
||||
|
||||
+18
-9
@@ -458,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(
|
||||
@@ -483,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="🛠️ Через поддержку",
|
||||
@@ -498,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}
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
+18
-8
@@ -43,8 +43,6 @@ class AuthMiddleware(BaseMiddleware):
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
@@ -68,8 +66,20 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_reg_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
|
||||
@@ -145,19 +155,19 @@ 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()
|
||||
@@ -194,4 +204,4 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
raise
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -14,7 +15,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = ("left", "kicked")
|
||||
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,
|
||||
@@ -32,46 +43,83 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
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.BAD_MEMBER_STATUS:
|
||||
|
||||
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)
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
# бот не админ или нет доступа к каналу
|
||||
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)
|
||||
|
||||
# если все каналы пройдены
|
||||
return await handler(event, data)
|
||||
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"""🔔 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
|
||||
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)
|
||||
|
||||
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}")
|
||||
|
||||
+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": "❌"
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
@@ -12,16 +12,18 @@ def is_registration_process(event: TelegramObject, current_state: Optional[str])
|
||||
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"]
|
||||
or event.data in ["sub_channel_check"]
|
||||
)
|
||||
)
|
||||
)
|
||||
return is_registration_process
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user