Compare commits

...

50 Commits

Author SHA1 Message Date
Egor 74544c9a9e Update Dockerfile 2025-09-12 22:52:41 +03:00
Egor 3314bb7694 Update docker-registry.yml 2025-09-12 22:52:31 +03:00
Egor c920bac33e Update docker-hub.yml 2025-09-12 22:52:14 +03:00
Egor e6881ef617 Update .env.example 2025-09-12 22:07:28 +03:00
Egor 94a7bb43f6 Update README.md 2025-09-12 21:54:15 +03:00
Egor 97841f0cbe Merge pull request #59 from Fr1ngg/dev
Активация/Деактивация приветственного сообщения
2025-09-12 21:47:04 +03:00
Egor de392bd4de Update start.py 2025-09-12 21:45:11 +03:00
Egor f2a3e5f2fc Update universal_migration.py 2025-09-12 21:27:34 +03:00
Egor 20a2566fe6 Update start.py 2025-09-12 21:23:47 +03:00
Egor 96bd411839 Update admin.py 2025-09-12 21:21:21 +03:00
Egor 773196fd84 Update welcome_text.py 2025-09-12 21:20:35 +03:00
Egor 26dc8b4963 Update welcome_text.py 2025-09-12 21:19:43 +03:00
Egor 06a9202c7c Update models.py 2025-09-12 21:18:06 +03:00
Egor 634786917d Merge pull request #58 from yazhog/main
Пропуск правил, рефкода при регистрации, отправка бэкап файлов в тг
2025-09-12 19:16:34 +03:00
yazhog 4ea839c30a Merge pull request #15 from yazhog/codex/add-telegram-backup-notification-settings
feat: send backups to telegram
2025-09-12 13:55:26 +03:00
yazhog 10f6ea382b feat: caption backup send 2025-09-12 13:52:53 +03:00
yazhog 929414e31f fix: send backup file without caption 2025-09-12 13:47:27 +03:00
yazhog cd87a3e773 chore: clarify backup env comment 2025-09-12 13:31:36 +03:00
yazhog 7a3168dcfd Merge pull request #14 from yazhog/codex/add-skip-rules-and-referral-options-to-.env
feat: allow skipping rules and referral prompts
2025-09-12 10:48:58 +03:00
yazhog 888f8fb01a Пропуск окна активации теста при регистрации 2025-09-12 10:37:03 +03:00
yazhog 6f06ec5aa0 feat: add skip options for rules and referral 2025-09-12 10:35:47 +03:00
Egor e10cb3f2bb Update Dockerfile 2025-09-12 09:51:59 +03:00
Egor 2edd3cb5ab Update docker-registry.yml 2025-09-12 09:51:49 +03:00
Egor d6d3dc83f5 Update docker-hub.yml 2025-09-12 09:51:24 +03:00
Egor ae392691b1 Merge pull request #57 from Fr1ngg/dev
🔄 Переключение лимита трафика
2025-09-12 09:11:21 +03:00
Egor 35a202d280 Add files via upload
🔄 Переключение лимита трафика
2025-09-12 09:09:32 +03:00
Egor e9a2d8a232 Update texts.py 2025-09-12 09:07:16 +03:00
Egor b76b99ca2e Update inline.py 2025-09-12 09:06:52 +03:00
Egor 8960ea9c10 Update subscription.py 2025-09-12 09:06:25 +03:00
Egor 9f94acb580 Merge pull request #56 from Fr1ngg/dev
Индивидуальный сброс устройств
2025-09-12 08:34:50 +03:00
Egor c9811fd518 Update inline.py 2025-09-12 08:32:29 +03:00
Egor e25fb1b178 Update subscription.py 2025-09-12 08:31:30 +03:00
Egor 779a03d008 Merge pull request #55 from Fr1ngg/dev
Переработка меню управлением кол-вом устройств, добавлена возможность уменьшить кол-во устройств на подписке
2025-09-12 04:19:46 +03:00
Egor f2a972aeea Update texts.py 2025-09-12 04:18:21 +03:00
Egor 2a9163a28b Update inline.py 2025-09-12 04:17:48 +03:00
Egor 20a628089d Update subscription.py 2025-09-12 04:16:07 +03:00
Egor cf01d917d1 Merge pull request #54 from yazhog/main
Все сообщения бота с прикрепленным логотипом
2025-09-12 02:51:24 +03:00
yazhog 1544da7e12 Образец логотипа 2025-09-11 22:44:06 +03:00
yazhog 94a533508e Merge pull request #13 from yazhog/codex/find-message-handling-functions-in-bot
Send referral messages as images
2025-09-11 22:40:56 +03:00
yazhog 98cdfcff81 merge main 2025-09-11 22:40:24 +03:00
yazhog 23ddaa81be Set connect button mode to guide 2025-09-11 22:29:31 +03:00
yazhog 667e988c47 refactor: add logo file setting 2025-09-11 22:29:26 +03:00
yazhog ea6b20fefa feat: toggle logo mode 2025-09-11 21:50:29 +03:00
yazhog 4e7154e817 Fix QR detection for returning logo 2025-09-10 02:04:29 +03:00
yazhog 7992cf9e26 Change QR photo back to logo when leaving referral QR 2025-09-10 01:58:26 +03:00
yazhog ea958ea6c0 refactor referral handlers with photo helper 2025-09-10 01:48:41 +03:00
yazhog 9b32de5fb2 Avoid replacing media when editing captions 2025-09-10 01:37:43 +03:00
yazhog b71d0d058f Respond to referral QR callbacks immediately 2025-09-10 01:26:14 +03:00
yazhog d36e353d9b Patch message methods to send logo image 2025-09-10 01:17:30 +03:00
yazhog e68d6ae19a Remove vpn logo asset from repo 2025-09-10 01:10:58 +03:00
22 changed files with 1692 additions and 487 deletions
+16 -1
View File
@@ -190,16 +190,25 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
# link - Открывает ссылку напрямую в браузере (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
# Пропустить запрос реферального кода
SKIP_REFERRAL_CODE=false
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
MONITORING_INTERVAL=60
INACTIVE_USER_DELETE_MONTHS=3
@@ -236,6 +245,12 @@ BACKUP_COMPRESSION=true
BACKUP_INCLUDE_LOGS=false
BACKUP_LOCATION=/app/data/backups
# Отправка бэкапов в телеграм
BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.7-$(git rev-parse --short HEAD)"
VERSION="v2.2.9-$(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.2.7-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.9-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.2.7-pr-$(git rev-parse --short HEAD)"
VERSION="v2.2.9-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -52,13 +52,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.7"
VERSION="v2.2.9"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.9-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.7-pr-$(git rev-parse --short HEAD)"
VERSION="v2.2.9-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.11-slim
ARG VERSION="v2.2.7"
ARG VERSION="v2.2.9"
ARG BUILD_DATE
ARG VCS_REF
+9 -62
View File
@@ -485,6 +485,7 @@ WEBHOOK_PATH=/webhook
- Настраиваемый триал-период
- Уведомления об истечении
- Плавный переход на платную версию
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
💰 **Удобные платежи**
- ⭐ Telegram Stars
@@ -521,11 +522,12 @@ WEBHOOK_PATH=/webhook
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами
- 📱 **НОВОЕ**: Изменение лимита устройств (1-X)
- 📊 **НОВОЕ**: Настройка лимитов трафика (0-10000 ГБ)
- 🌍 **НОВОЕ**: Мультивыбор серверов
- 🔄 **НОВОЕ**: Сброс HWID устройств
- 👥 **НОВОЕ**: Детальная реферальная статистика в профиле пользователя
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ)
- 🌍 Мультивыбор серверов
- 🔄 Сброс HWID устройств
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
- 🚫 Блокировка/разблокировка/удаление
🎫 **Промо-система**
@@ -545,7 +547,7 @@ WEBHOOK_PATH=/webhook
- 🔔 Автоуведомления о продлении
- 💬 Система поддержки с HTML разметкой
- 📝 Настройка правил сервиса
- Настраиваемое приветственное сообщение с предложением активации триала
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала
@@ -556,6 +558,7 @@ WEBHOOK_PATH=/webhook
- ♻️ Выход обновлений бота
- 🚧 Потеря соелинения с апи Remnawave
- 🗄️ **Бекапы/Восстановление бд**
- 🗄️ Отправка бд файлов в отдельный чат/топики
🗄️ **Бекапы/Восстановление**
- Ручной запуск бекапа
@@ -568,60 +571,6 @@ WEBHOOK_PATH=/webhook
---
## 🆕 Недавние обновления (v2.2.1)
### 🛡️ **Защищенная реферальная система**
- **Защита от абуза**: Бонусы выдаются только при реальном пополнении от 100₽
- **Новая логика наград**: При первом пополнении ≥100₽ - новый пользователь и пригласивший получают по 100₽
- **Комиссионная система**: Пригласивший получает 25% с последующих пополнений
- **Автоматическая миграция**: Обновление статуса существующих пользователей с историей пополнений
### 🔔 **Умные уведомления**
- **Реферальные уведомления**: Автоматические сообщения при переходе по ссылке, первом пополнении и получении комиссий
- **Контроль повторов**: Защита от дублирования уведомлений и повторного получения бонусов
- **Настраиваемые попытки**: Конфигурируемое количество попыток отправки уведомлений
### 📊 **Расширенная статистика рефералов**
- **Детальная аналитика**: Количество приглашенных, активных рефералов, конверсия и доходы
- **Список рефералов**: Просмотр всех приглашенных пользователей с пагинацией
- **Топ-рефералы**: Самые прибыльные рефералы с индикацией активности
- **Доходы по периодам**: Разбивка заработка по типам начислений
### 🔧 **Исправления админ-панели**
- **Статистика**: Исправлена неработающая кнопка "Обновить" в статистике
- **Промокоды**: Исправлена кнопка "Статистика" в разделе управления промокодами
- **Навигация**: Синхронизированы callback_data между клавиатурами и обработчиками
- **Реферальная статистика**: Добавлена детальная реферальная информация в профилях пользователей
### 📱 **Улучшения пользовательского интерфейса**
- **User-friendly**: Улучшен интерфейс страницы подписки для лучшего UX
- **Производительность**: Оптимизированы SQL-запросы для работы с реферальной статистикой
- **Обработка ошибок**: Улучшена обработка ошибок при обновлении данных
---
## 🆕 Недавние обновления
### 🛠️ **Управление серверами**
- **Мультивыбор серверов**: Админы могут назначать пользователям несколько серверов через удобный переключатель
- **Умная логика отображения**: Показывает активные серверы + уже назначенные пользователю неактивные серверы
- **Автоматический пропуск**: При наличии только одного сервера шаг выбора автоматически пропускается
- **Синхронизация в реальном времени**: Все изменения серверов автоматически синхронизируются с Remnawave панелью
### 📱 **Управление устройствами**
- **Изменение лимита устройств**: Админы могут менять количество разрешенных устройств (1-X)
- **Быстрые кнопки**: Предустановленные значения 1, 2, 3, 5, 10 устройств
- **Сброс HWID**: Функция сброса привязанных устройств пользователя
- **Гибкая настройка**: Отдельные лимиты для триал и платных подписок
### 📊 **Управление трафиком**
- **Настройка лимитов трафика**: Возможность установки лимита от 0 ГБ до 10000 ГБ
- **Безлимитный режим**: Опция установки безлимитного трафика (0 ГБ)
- **Быстрые настройки**: Предустановленные значения 50, 100, 500, 1000 ГБ
- **Два режима продажи**: Фиксированный трафик или выбираемые пакеты
---
## 🐳 Docker развертывание
### 📄 docker-compose.yml
@@ -918,8 +867,6 @@ bedolaga_bot/
### 🏥 Health Checks
- **Основной**: `http://localhost:8081/health`
- **YooKassa**: `http://localhost:8082/health`
- **Tribute**: `http://localhost:8081/tribute-webhook`
### 🔧 Полезные команды
```bash
+3
View File
@@ -30,6 +30,9 @@ from app.handlers.admin import (
)
from app.handlers.stars_payments import register_stars_handlers
from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
+22 -2
View File
@@ -138,7 +138,11 @@ class Settings(BaseSettings):
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
ENABLE_LOGO_MODE: bool = True
LOGO_FILE: str = "vpn_logo.png"
SKIP_RULES_ACCEPT: bool = False
SKIP_REFERRAL_CODE: bool = False
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
@@ -164,6 +168,9 @@ class Settings(BaseSettings):
BACKUP_COMPRESSION: bool = True
BACKUP_INCLUDE_LOGS: bool = False
BACKUP_LOCATION: str = "/app/data/backups"
BACKUP_SEND_ENABLED: bool = False
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -411,9 +418,22 @@ class Settings(BaseSettings):
return None
def is_admin_notifications_enabled(self) -> bool:
return (self.ADMIN_NOTIFICATIONS_ENABLED and
return (self.ADMIN_NOTIFICATIONS_ENABLED and
self.get_admin_notifications_chat_id() is not None)
def get_backup_send_chat_id(self) -> Optional[int]:
if not self.BACKUP_SEND_CHAT_ID:
return None
try:
return int(self.BACKUP_SEND_CHAT_ID)
except (ValueError, TypeError):
return None
def is_backup_send_enabled(self) -> bool:
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_referral_settings(self) -> Dict:
return {
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
+68 -3
View File
@@ -14,6 +14,7 @@ async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.where(WelcomeText.is_enabled == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
@@ -23,8 +24,72 @@ async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
return None
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
if welcome_text:
return {
'text': welcome_text.text_content,
'is_enabled': welcome_text.is_enabled,
'id': welcome_text.id
}
return {
'text': await get_current_welcome_text_or_default(),
'is_enabled': True,
'id': None
}
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
try:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
if welcome_text:
welcome_text.is_enabled = not welcome_text.is_enabled
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
status = "включен" if welcome_text.is_enabled else "отключен"
logger.info(f"Приветственный текст {status} администратором {admin_id}")
return welcome_text.is_enabled
else:
default_text = await get_current_welcome_text_or_default()
new_welcome_text = WelcomeText(
text_content=default_text,
is_active=True,
is_enabled=True,
created_by=admin_id
)
db.add(new_welcome_text)
await db.commit()
await db.refresh(new_welcome_text)
logger.info(f"Создан и включен дефолтный приветственный текст администратором {admin_id}")
return True
except Exception as e:
logger.error(f"Ошибка при переключении статуса приветственного текста: {e}")
await db.rollback()
return False
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
try:
current_settings = await get_current_welcome_text_settings(db)
current_enabled_status = current_settings.get('is_enabled', True)
await db.execute(
update(WelcomeText).values(is_active=False)
)
@@ -32,6 +97,7 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
new_welcome_text = WelcomeText(
text_content=text_content,
is_active=True,
is_enabled=current_enabled_status,
created_by=admin_id
)
@@ -54,7 +120,7 @@ async def get_current_welcome_text_or_default() -> str:
f"✅ До 1 Гбит/с скорость "
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
f"✅ Современные протоколы — максимум защиты и анонимности "
f"👉 Всего 99₽/мес за 1 устройство "
f"💉 Всего 99₽/мес за 1 устройство "
f"👇 Жмите кнопку и подключайтесь!"
)
@@ -85,11 +151,10 @@ def replace_placeholders(text: str, user) -> str:
return result
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
"""Получает приветственный текст с заменой плейсхолдеров для конкретного пользователя"""
welcome_text = await get_active_welcome_text(db)
if not welcome_text:
welcome_text = await get_current_welcome_text_or_default()
return None
if isinstance(user, str):
class SimpleUser:
+1
View File
@@ -667,6 +667,7 @@ class WelcomeText(Base):
id = Column(Integer, primary_key=True, index=True)
text_content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
is_enabled = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+53 -7
View File
@@ -248,11 +248,49 @@ async def create_user_messages_table():
logger.error(f"Ошибка создания таблицы user_messages: {e}")
return False
async def add_welcome_text_is_enabled_column():
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
if column_exists:
logger.info("Колонка is_enabled уже существует в таблице welcome_texts")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT 1 NOT NULL"
elif db_type == 'postgresql':
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
elif db_type == 'mysql':
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
else:
logger.error(f"Неподдерживаемый тип БД для добавления колонки: {db_type}")
return False
await conn.execute(text(alter_sql))
logger.info("✅ Поле is_enabled добавлено в таблицу welcome_texts")
if db_type == 'sqlite':
update_sql = "UPDATE welcome_texts SET is_enabled = 1 WHERE is_enabled IS NULL"
else:
update_sql = "UPDATE welcome_texts SET is_enabled = TRUE WHERE is_enabled IS NULL"
result = await conn.execute(text(update_sql))
updated_count = result.rowcount
logger.info(f"Обновлено {updated_count} существующих записей welcome_texts")
return True
except Exception as e:
logger.error(f"Ошибка при добавлении поля is_enabled: {e}")
return False
async def create_welcome_texts_table():
table_exists = await check_table_exists('welcome_texts')
if table_exists:
logger.info("Таблица welcome_texts уже существует")
return True
return await add_welcome_text_is_enabled_column()
try:
async with engine.begin() as conn:
@@ -264,6 +302,7 @@ async def create_welcome_texts_table():
id INTEGER PRIMARY KEY AUTOINCREMENT,
text_content TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_enabled BOOLEAN DEFAULT 1 NOT NULL,
created_by INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -271,6 +310,7 @@ async def create_welcome_texts_table():
);
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
"""
@@ -280,6 +320,7 @@ async def create_welcome_texts_table():
id SERIAL PRIMARY KEY,
text_content TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
created_by INTEGER NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -287,6 +328,7 @@ async def create_welcome_texts_table():
);
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
"""
@@ -296,6 +338,7 @@ async def create_welcome_texts_table():
id INT AUTO_INCREMENT PRIMARY KEY,
text_content TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -303,6 +346,7 @@ async def create_welcome_texts_table():
);
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
"""
else:
@@ -310,7 +354,7 @@ async def create_welcome_texts_table():
return False
await conn.execute(text(create_sql))
logger.info("Таблица welcome_texts успешно создана")
logger.info("Таблица welcome_texts успешно создана с полем is_enabled")
return True
except Exception as e:
@@ -588,10 +632,10 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей user_messages")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
welcome_texts_created = await create_welcome_texts_table()
if welcome_texts_created:
logger.info("✅ Таблица welcome_texts готова")
logger.info("✅ Таблица welcome_texts готова с полем is_enabled")
else:
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
@@ -644,6 +688,7 @@ async def run_universal_migration():
logger.info("✅ Реферальная система обновлена")
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Дубликаты подписок исправлены")
return True
@@ -660,6 +705,7 @@ async def check_migration_status():
"cryptobot_table": False,
"user_messages_table": False,
"welcome_texts_table": False,
"welcome_texts_is_enabled_column": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
}
@@ -667,13 +713,12 @@ async def check_migration_status():
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
@@ -691,6 +736,7 @@ async def check_migration_status():
"cryptobot_table": "Таблица CryptoBot payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
+92 -26
View File
@@ -12,7 +12,9 @@ from app.database.crud.welcome_text import (
get_active_welcome_text,
set_welcome_text,
get_current_welcome_text_or_default,
get_available_placeholders
get_available_placeholders,
get_current_welcome_text_settings,
toggle_welcome_text_status
)
logger = logging.getLogger(__name__)
@@ -42,11 +44,41 @@ async def show_welcome_text_panel(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
await callback.message.edit_text(
"👋 Управление приветственным текстом\n\n"
"Здесь вы можете изменить текст, который показывается новым пользователям после регистрации.\n\n"
"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language)
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
new_status = await toggle_welcome_text_status(db, db_user.id)
status_emoji = "🟢" if new_status else "🔴"
status_text = "включено" if new_status else "отключено"
action_text = "включены" if new_status else "отключены"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"✅ Приветственные сообщения {action_text}!\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, new_status),
parse_mode="HTML"
)
await callback.answer()
@@ -57,22 +89,27 @@ async def show_current_welcome_text(
db_user: User,
db: AsyncSession
):
current_text = await get_active_welcome_text(db)
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
is_enabled = settings['is_enabled']
if not current_text:
current_text = await get_current_welcome_text_or_default()
if not settings['id']:
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
status_emoji = "🟢" if is_enabled else "🔴"
status_text = "включено" if is_enabled else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
await callback.message.edit_text(
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"{status}\n\n"
f"<code>{current_text}</code>\n\n"
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language),
reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled),
parse_mode="HTML"
)
await callback.answer()
@@ -84,6 +121,7 @@ async def show_placeholders_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
@@ -99,7 +137,7 @@ async def show_placeholders_help(
await callback.message.edit_text(
help_text,
reply_markup=get_welcome_text_keyboard(db_user.language),
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -111,11 +149,12 @@ async def show_formatting_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language),
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -128,10 +167,8 @@ async def start_edit_welcome_text(
db_user: User,
db: AsyncSession
):
current_text = await get_active_welcome_text(db)
if not current_text:
current_text = await get_current_welcome_text_or_default()
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
@@ -169,21 +206,27 @@ async def process_welcome_text_edit(
success = await set_welcome_text(db, new_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
await message.answer(
f"✅ Приветственный текст успешно обновлен!\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language),
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language)
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
)
await state.clear()
@@ -199,18 +242,24 @@ async def reset_welcome_text(
success = await set_welcome_text(db, default_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Стандартный текст:\n"
f"<code>{default_text}</code>\n\n"
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
reply_markup=get_welcome_text_keyboard(db_user.language),
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language)
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
)
await callback.answer()
@@ -232,13 +281,25 @@ async def show_preview_welcome_text(
test_user = TestUser()
preview_text = await get_welcome_text_for_user(db, test_user)
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language),
parse_mode="HTML"
)
settings = await get_current_welcome_text_settings(db)
if preview_text:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
else:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"🔴 Приветственные сообщения отключены.\n"
f"Новые пользователи не будут получать приветственный текст после регистрации.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
def register_welcome_text_handlers(dp: Dispatcher):
@@ -247,6 +308,11 @@ def register_welcome_text_handlers(dp: Dispatcher):
F.data == "welcome_text_panel"
)
dp.callback_query.register(
toggle_welcome_text,
F.data == "toggle_welcome_text"
)
dp.callback_query.register(
show_current_welcome_text,
F.data == "show_welcome_text"
+44 -46
View File
@@ -11,6 +11,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.utils.photo_message import edit_or_answer_photo
from app.utils.user_utils import (
get_detailed_referral_list,
get_referral_analytics,
@@ -90,20 +91,12 @@ async def show_referral_info(
referral_text += "\n"
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
if callback.message.text:
await callback.message.edit_text(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await callback.message.delete()
await callback.message.answer(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
)
await edit_or_answer_photo(
callback,
referral_text,
get_referral_keyboard(db_user.language),
)
await callback.answer()
@@ -111,6 +104,8 @@ async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
await callback.answer()
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
@@ -144,7 +139,6 @@ async def show_referral_qr(
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
reply_markup=keyboard,
)
await callback.answer()
async def show_detailed_referral_list(
@@ -154,15 +148,17 @@ async def show_detailed_referral_list(
page: int = 1
):
texts = get_texts(db_user.language)
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
if not referrals_data['referrals']:
await callback.message.edit_text(
await edit_or_answer_photo(
callback,
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
])
types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
parse_mode=None,
)
await callback.answer()
return
@@ -205,14 +201,14 @@ async def show_detailed_referral_list(
keyboard.append(nav_buttons)
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
text=texts.BACK,
callback_data="menu_referrals"
)])
await callback.message.edit_text(
await edit_or_answer_photo(
callback,
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@@ -223,31 +219,31 @@ async def show_referral_analytics(
db: AsyncSession
):
texts = get_texts(db_user.language)
analytics = await get_referral_analytics(db, db_user.id)
text = f"📊 <b>Аналитика рефералов</b>\n\n"
text += f"💰 <b>Доходы по периодам:</b>\n"
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
if analytics['top_referrals']:
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
for i, ref in enumerate(analytics['top_referrals'], 1):
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
text += "\n"
text += "📈 Продолжайте развивать свою реферальную сеть!"
await callback.message.edit_text(
await edit_or_answer_photo(
callback,
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
]),
parse_mode="HTML"
)
await callback.answer()
@@ -257,34 +253,36 @@ async def create_invite_message(
db_user: User
):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
invite_text += f"🚀 Быстрое подключение\n"
invite_text += f"🌍 Серверы по всему миру\n"
invite_text += f"🔒 Надежная защита\n\n"
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="📤 Поделиться",
switch_inline_query=invite_text
switch_inline_query=invite_text
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)]
])
await callback.message.edit_text(
f"📝 <b>Приглашение создано!</b>\n\n"
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
f"<code>{invite_text}</code>",
reply_markup=keyboard,
parse_mode="HTML"
await edit_or_answer_photo(
callback,
(
f"📝 <b>Приглашение создано!</b>\n\n"
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
f"<code>{invite_text}</code>"
),
keyboard,
)
await callback.answer()
+128 -35
View File
@@ -224,13 +224,36 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен русский язык по умолчанию")
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
await complete_registration(message, state, db)
else:
try:
await message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info("🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration(message, state, db)
return
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
@@ -274,25 +297,29 @@ async def process_rules_accept(
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
@@ -390,6 +417,8 @@ async def complete_registration_from_callback(
):
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
from sqlalchemy.orm import selectinload
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -400,6 +429,8 @@ async def complete_registration_from_callback(
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
await db.refresh(existing_user, ['subscription'])
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
@@ -455,7 +486,7 @@ async def complete_registration_from_callback(
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
await db.refresh(existing_user, ['subscription'])
user = existing_user
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
@@ -475,6 +506,7 @@ async def complete_registration_from_callback(
referred_by_id=referrer_id,
referral_code=referral_code
)
await db.refresh(user, ['subscription'])
else:
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
@@ -487,7 +519,7 @@ async def complete_registration_from_callback(
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
@@ -500,20 +532,49 @@ async def complete_registration_from_callback(
await state.clear()
from app.database.crud.welcome_text import get_welcome_text_for_user
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
offer_text = await get_welcome_text_for_user(db, user_name)
offer_text = await get_welcome_text_for_user(db, callback.from_user)
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
)
except Exception as e:
logger.error(f"Ошибка при отправке предложения триала: {e}")
if offer_text:
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
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)
try:
await callback.message.answer(
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
),
parse_mode="HTML"
)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
await callback.message.answer(f"Добро пожаловать, {user.full_name}!")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
async def complete_registration(
message: types.Message,
state: FSMContext,
@@ -531,6 +592,8 @@ async def complete_registration(
if data.get('referral_code') and not existing_user.referred_by_id:
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
await db.refresh(existing_user, ['subscription'])
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
@@ -586,7 +649,7 @@ async def complete_registration(
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
await db.refresh(existing_user, ['subscription'])
user = existing_user
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
@@ -606,6 +669,7 @@ async def complete_registration(
referred_by_id=referrer_id,
referral_code=referral_code
)
await db.refresh(user, ['subscription'])
else:
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
@@ -618,7 +682,7 @@ async def complete_registration(
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
@@ -631,16 +695,45 @@ async def complete_registration(
await state.clear()
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
try:
await message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
)
except Exception as e:
logger.error(f"Ошибка при отправке предложения триала: {e}")
if offer_text:
try:
await message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
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)
try:
await message.answer(
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
),
parse_mode="HTML"
)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
await message.answer(f"Добро пожаловать, {user.full_name}!")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -666,19 +666,32 @@ def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_welcome_text_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup:
toggle_text = "🔴 Отключить" if is_enabled else "🟢 Включить"
toggle_callback = "toggle_welcome_text"
keyboard = [
[
InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)
],
[
InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
],
[
InlineKeyboardButton(text="👁️ Предпросмотр", callback_data="preview_welcome_text"),
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text")
],
[
InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"),
InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
],
[
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text"),
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+207 -50
View File
@@ -2,6 +2,8 @@ from typing import List, Optional
from aiogram import types
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from datetime import datetime
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.texts import get_texts
@@ -24,7 +26,7 @@ def get_post_registration_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
text="🚀 Подключиться бесплатно 🚀", callback_data="trial_activate"
)
],
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
@@ -195,40 +197,6 @@ def get_subscription_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
])
keyboard.extend([
[
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
],
[
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
])
keyboard.insert(-2, [
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -778,7 +746,7 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
def get_change_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
@@ -793,32 +761,77 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscri
buttons = []
for count in [1, 2, 3, 4, 5]:
new_total = current_devices + count
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
continue
price_per_month = count * device_price_per_month
total_price = price_per_month * months_multiplier
if language == "ru":
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price//100}{period_text}"
min_devices = 1
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
for devices_count in range(start_range, end_range):
if devices_count == current_devices:
emoji = ""
action_text = " (текущее)"
price_text = ""
elif devices_count > current_devices:
emoji = ""
additional_devices = devices_count - current_devices
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
chargeable_devices = new_chargeable - current_chargeable
if chargeable_devices > 0:
price_per_month = chargeable_devices * device_price_per_month
total_price = price_per_month * months_multiplier
price_text = f" (+{total_price//100}{period_text})"
action_text = ""
else:
price_text = " (бесплатно)"
action_text = ""
else:
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price//100}{period_text}"
emoji = ""
action_text = ""
price_text = " (без возврата)"
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
])
if current_devices < start_range or current_devices >= end_range:
current_button = f"{current_devices} устр. (текущее)"
buttons.insert(0, [
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
])
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="subscription_settings"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Подтвердить изменение",
callback_data=f"confirm_change_devices_{new_devices_count}_{price}"
)
],
[
InlineKeyboardButton(
text="❌ Отмена",
callback_data="subscription_settings"
)
]
])
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
@@ -1109,3 +1122,147 @@ def get_cryptobot_payment_keyboard(
)
]
])
def get_devices_management_keyboard(
devices: List[dict],
pagination,
language: str = "ru"
) -> InlineKeyboardMarkup:
keyboard = []
for i, device in enumerate(devices):
platform = device.get('platform', 'Unknown')
device_model = device.get('deviceModel', 'Unknown')
device_info = f"{platform} - {device_model}"
if len(device_info) > 25:
device_info = device_info[:22] + "..."
keyboard.append([
InlineKeyboardButton(
text=f"🔄 {device_info}",
callback_data=f"reset_device_{i}_{pagination.page}"
)
])
if pagination.total_pages > 1:
nav_row = []
if pagination.has_prev:
nav_row.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"devices_page_{pagination.prev_page}"
)
)
nav_row.append(
InlineKeyboardButton(
text=f"{pagination.page}/{pagination.total_pages}",
callback_data="current_page"
)
)
if pagination.has_next:
nav_row.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"devices_page_{pagination.next_page}"
)
)
keyboard.append(nav_row)
keyboard.append([
InlineKeyboardButton(
text="🔄 Сбросить все устройства",
callback_data="reset_all_devices"
)
])
keyboard.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="subscription_settings"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_updated_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text="🌐 Добавить страны", callback_data="subscription_add_countries")
])
keyboard.extend([
[
InlineKeyboardButton(text="📱 Изменить устройства", callback_data="subscription_change_devices")
],
[
InlineKeyboardButton(text="🔧 Управление устройствами", callback_data="subscription_manage_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text="🔄 Переключить трафик", callback_data="subscription_switch_traffic")
])
keyboard.insert(-2, [
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Да, сбросить это устройство",
callback_data=f"confirm_reset_device_{device_index}_{page}"
)
],
[
InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"devices_page_{page}"
)
]
])
def get_device_management_help_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="❓ Как подключить устройство заново?",
callback_data="device_connection_help"
)
],
[
InlineKeyboardButton(
text="🔧 Управление устройствами",
callback_data="subscription_manage_devices"
)
],
[
InlineKeyboardButton(
text="⬅️ К подписке",
callback_data="menu_subscription"
)
]
])
+86
View File
@@ -162,6 +162,50 @@ class RussianTexts(Texts):
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
CHANGE_DEVICES_INFO = """
📱 <b>Изменение количества устройств</b>
Текущий лимит: {current_devices} устройств
Выберите новое количество устройств:
💡 <b>Важно:</b>
При увеличении - доплата пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
"""
CHANGE_DEVICES_CONFIRM = """
📱 <b>Подтверждение изменения</b>
Текущее количество: {current_devices} устройств
Новое количество: {new_devices} устройств
Действие: {action}
💰 {cost}
Подтвердить изменение?
"""
CHANGE_DEVICES_SUCCESS_INCREASE = """
Количество устройств увеличено!
📱 Было: {old_count} Стало: {new_count}
💰 Списано: {amount}
"""
CHANGE_DEVICES_SUCCESS_DECREASE = """
Количество устройств уменьшено!
📱 Было: {old_count} Стало: {new_count}
Возврат средств не производится
"""
DEVICES_NO_CHANGE = "ℹ️ Количество устройств не изменилось"
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
@@ -408,6 +452,48 @@ class RussianTexts(Texts):
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
SWITCH_TRAFFIC_INFO = """
🔄 <b>Переключение лимита трафика</b>
Текущий лимит: {current_traffic}
Выберите новый лимит трафика:
💡 <b>Важно:</b>
При увеличении - доплата за разницу пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
Счетчик использованного трафика НЕ сбрасывается
"""
SWITCH_TRAFFIC_CONFIRM = """
🔄 <b>Подтверждение переключения трафика</b>
Текущий лимит: {current_traffic}
Новый лимит: {new_traffic}
Действие: {action}
💰 {cost}
Подтвердить переключение?
"""
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
Лимит трафика увеличен!
📊 Было: {old_traffic} Стало: {new_traffic}
💰 Списано: {amount}
"""
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
Лимит трафика уменьшен!
📊 Было: {old_traffic} Стало: {new_traffic}
Возврат средств не производится
"""
TRAFFIC_NO_CHANGE = "ℹ️ Лимит трафика не изменился"
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
+33 -3
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from dataclasses import dataclass, asdict
import aiofiles
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, inspect
from sqlalchemy.orm import selectinload
@@ -197,6 +198,8 @@ class BackupService:
await self._send_backup_notification(
"success", message, str(backup_path)
)
await self._send_backup_file_to_chat(str(backup_path))
return True, message, str(backup_path)
@@ -536,9 +539,9 @@ class BackupService:
await asyncio.sleep(3600)
async def _send_backup_notification(
self,
event_type: str,
message: str,
self,
event_type: str,
message: str,
file_path: str = None
):
try:
@@ -570,5 +573,32 @@ class BackupService:
except Exception as e:
logger.error(f"Ошибка отправки уведомления о бекапе: {e}")
async def _send_backup_file_to_chat(self, file_path: str):
try:
if not settings.is_backup_send_enabled():
return
chat_id = settings.get_backup_send_chat_id()
if not chat_id:
return
send_kwargs = {
'chat_id': chat_id,
'document': FSInputFile(file_path),
'caption': (
f"📦 <b>Резервная копия</b>\n\n"
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
),
'parse_mode': 'HTML'
}
if settings.BACKUP_SEND_TOPIC_ID:
send_kwargs['message_thread_id'] = settings.BACKUP_SEND_TOPIC_ID
await self.bot.send_document(**send_kwargs)
logger.info(f"Бекап отправлен в чат {chat_id}")
except Exception as e:
logger.error(f"Ошибка отправки бекапа в чат: {e}")
backup_service = BackupService()
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
from aiogram.types import Message, FSInputFile, InputMediaPhoto
from app.config import settings
LOGO_PATH = Path(settings.LOGO_FILE)
def is_qr_message(message: Message) -> bool:
return bool(message.caption and message.caption.startswith("\U0001F517 Ваша реферальная ссылка"))
_original_answer = Message.answer
_original_edit_text = Message.edit_text
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
if LOGO_PATH.exists():
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
return await _original_answer(self, text, **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():
media = FSInputFile(LOGO_PATH)
media_kwargs = {"media": media, "caption": text}
if "parse_mode" in kwargs:
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
return await _original_edit_text(self, text, **kwargs)
def patch_message_methods():
if not settings.ENABLE_LOGO_MODE:
return
Message.answer = _answer_with_photo
Message.edit_text = _edit_with_photo
+58
View File
@@ -0,0 +1,58 @@
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile, InputMediaPhoto
from app.config import settings
from .message_patch import LOGO_PATH, is_qr_message
def _resolve_media(message: types.Message):
if message.photo and not is_qr_message(message):
return message.photo[-1].file_id
return FSInputFile(LOGO_PATH)
async def edit_or_answer_photo(
callback: types.CallbackQuery,
caption: str,
keyboard: types.InlineKeyboardMarkup,
parse_mode: str | None = "HTML",
) -> None:
if not settings.ENABLE_LOGO_MODE:
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
else:
await callback.message.edit_text(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
return
media = _resolve_media(callback.message)
try:
await callback.message.edit_media(
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
FSInputFile(LOGO_PATH),
caption=caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
+2
View File
@@ -67,6 +67,8 @@ services:
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB