Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e664aad8 | |||
| b365394ff4 | |||
| e53549d7cc | |||
| 743e99af85 | |||
| 9616fe8d44 | |||
| 4ae6361599 | |||
| 89c12e6ba9 | |||
| 5d6bc82d8a | |||
| a4f3fe5a49 | |||
| 3a58a3490a | |||
| e2408702ea | |||
| 3f1f27e5aa | |||
| bbd0ec2edd | |||
| eb22b19d98 | |||
| 45b9d44fd0 | |||
| a92003f0f5 | |||
| 4ac9387ef2 | |||
| a24b5e8145 | |||
| 7244e3978b | |||
| 41cabf5e0e | |||
| 338bc03cd6 | |||
| 985260144e | |||
| b487925fbf | |||
| 07a89443b3 | |||
| 54ebb2d96d | |||
| 34a7cd1f6c | |||
| 455b8fc709 | |||
| 591b422302 | |||
| fc73c34a03 | |||
| cc1c6e3088 | |||
| 966f7124c4 | |||
| 1cebec5af4 | |||
| d31b5097ff | |||
| 21a8fe4025 | |||
| 6a4adcfe0c | |||
| c9acd46cd3 | |||
| 6018113a3d | |||
| fe0825957a | |||
| d0cef51e35 | |||
| 2aa0d11f4c | |||
| b8a1aa3f3c | |||
| 80e95bc77b | |||
| 87af0312cb | |||
| e988c9aa14 | |||
| cdee98f690 | |||
| 268c619ad6 | |||
| 352434ae35 | |||
| b1e39bae32 | |||
| 44a2c929c4 | |||
| 42a9865fac | |||
| c730863162 | |||
| b58db19127 | |||
| 2820c2d942 | |||
| 0be172c675 | |||
| b26fd77190 | |||
| 9317a1703a | |||
| f166279507 | |||
| 60216519af | |||
| ed83d31b22 | |||
| cf6e2955c7 | |||
| 818189e8e0 | |||
| 82e75f2170 | |||
| 1b265fd95b | |||
| 7a8905e0bc | |||
| 6d8ba6d30c |
@@ -12,6 +12,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_SUB_REQUIRED=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
@@ -46,6 +51,14 @@ REMNAWAVE_PASSWORD=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.1"
|
||||
VERSION="v2.3.3"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG VERSION="v2.3.1"
|
||||
ARG VERSION="v2.3.3"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ 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,7 +76,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
@@ -90,6 +91,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -14,6 +16,10 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
@@ -36,6 +42,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -252,6 +259,33 @@ class Settings(BaseSettings):
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def format_remnawave_user_description(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}"
|
||||
template_for_formatting = template.replace("@{username}", "{username}")
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name,
|
||||
"username": f"@{username_clean}" if username_clean else "",
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id)
|
||||
})
|
||||
|
||||
description = template_for_formatting.format_map(values)
|
||||
|
||||
if not username_clean:
|
||||
description = re.sub(r'@(?=\W|$)', '', description)
|
||||
description = re.sub(r'\(\s*\)', '', description)
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
|
||||
+139
-3
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,10 +53,37 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
@@ -79,4 +106,113 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
async def get_all_rules_versions(
|
||||
db: AsyncSession,
|
||||
language: str = "ru",
|
||||
limit: int = 10
|
||||
) -> list[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(ServiceRule.language == language)
|
||||
.order_by(ServiceRule.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def restore_rules_version(
|
||||
db: AsyncSession,
|
||||
rule_id: int,
|
||||
language: str = "ru"
|
||||
) -> Optional[ServiceRule]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.id == rule_id,
|
||||
ServiceRule.language == language
|
||||
)
|
||||
)
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
title=rule_to_restore.title,
|
||||
content=rule_to_restore.content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(restored_rule)
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
try:
|
||||
active_result = await db.execute(
|
||||
select(ServiceRule).where(ServiceRule.is_active == True)
|
||||
)
|
||||
active_rules = active_result.scalars().all()
|
||||
|
||||
all_result = await db.execute(select(ServiceRule))
|
||||
all_rules = all_result.scalars().all()
|
||||
|
||||
languages_stats = {}
|
||||
for rule in active_rules:
|
||||
lang = rule.language
|
||||
if lang not in languages_stats:
|
||||
languages_stats[lang] = {
|
||||
'active_count': 0,
|
||||
'last_updated': None,
|
||||
'content_length': 0
|
||||
}
|
||||
|
||||
languages_stats[lang]['active_count'] += 1
|
||||
languages_stats[lang]['content_length'] = len(rule.content)
|
||||
|
||||
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
|
||||
languages_stats[lang]['last_updated'] = rule.updated_at
|
||||
|
||||
return {
|
||||
'total_active': len(active_rules),
|
||||
'total_all_time': len(all_rules),
|
||||
'languages': languages_stats,
|
||||
'total_languages': len(languages_stats)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
|
||||
return {
|
||||
'total_active': 0,
|
||||
'total_all_time': 0,
|
||||
'languages': {},
|
||||
'total_languages': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -575,6 +575,10 @@ class BroadcastHistory(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
@@ -583,7 +587,6 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
|
||||
@@ -361,6 +361,49 @@ async def create_welcome_texts_table():
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
async def add_media_fields_to_broadcast_history():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
|
||||
|
||||
media_fields = {
|
||||
'has_media': 'BOOLEAN DEFAULT FALSE',
|
||||
'media_type': 'VARCHAR(20)',
|
||||
'media_file_id': 'VARCHAR(255)',
|
||||
'media_caption': 'TEXT'
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
for field_name, field_type in media_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f"Добавление поля {field_name} в таблицу broadcast_history")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT 0')
|
||||
elif db_type == 'postgresql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
elif db_type == 'mysql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
|
||||
alter_sql = f"ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}"
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f"✅ Поле {field_name} успешно добавлено")
|
||||
else:
|
||||
logger.info(f"Поле {field_name} уже существует в broadcast_history")
|
||||
|
||||
logger.info("✅ Все поля медиа в broadcast_history готовы")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -639,6 +682,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
|
||||
media_fields_added = await add_media_fields_to_broadcast_history()
|
||||
if media_fields_added:
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -689,6 +739,7 @@ async def run_universal_migration():
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
@@ -706,6 +757,7 @@ async def check_migration_status():
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
@@ -719,6 +771,14 @@ async def check_migration_status():
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
await check_column_exists('broadcast_history', 'media_file_id') and
|
||||
await check_column_exists('broadcast_history', 'media_caption')
|
||||
)
|
||||
status["broadcast_history_media_fields"] = media_fields_exist
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -737,6 +797,7 @@ async def check_migration_status():
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
@@ -755,12 +816,14 @@ async def check_migration_status():
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
welcome_texts_count = await conn.execute(text("SELECT COUNT(*) FROM welcome_texts"))
|
||||
broadcasts_count = await conn.execute(text("SELECT COUNT(*) FROM broadcast_history"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
welcome_count = welcome_texts_count.fetchone()[0]
|
||||
broadcast_count = broadcasts_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов")
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
|
||||
+143
-2
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -14,6 +15,8 @@ from app.keyboards.admin import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,10 +35,18 @@ async def show_admin_panel(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.get_system_statistics()
|
||||
users_online = stats.get("system", {}).get("users_online", 0)
|
||||
system_stats = stats.get("system", {})
|
||||
users_online = system_stats.get("users_online", 0)
|
||||
users_today = system_stats.get("users_last_day", 0)
|
||||
users_week = system_stats.get("users_last_week", 0)
|
||||
admin_text = admin_text.replace(
|
||||
"\n\nВыберите раздел для управления:",
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}\n\nВыберите раздел для управления:"
|
||||
(
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
|
||||
f"\n- 📅 Онлайн сегодня: {users_today}"
|
||||
f"\n- 🗓️ На этой неделе: {users_week}"
|
||||
"\n\nВыберите раздел для управления:"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
|
||||
@@ -137,6 +148,121 @@ async def show_system_submenu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if stats['total_active'] == 0:
|
||||
await message.reply(
|
||||
"ℹ️ <b>Правила уже очищены</b>\n\n"
|
||||
"В системе нет активных правил. Используются стандартные правила по умолчанию."
|
||||
)
|
||||
return
|
||||
|
||||
success = await clear_all_rules(db, db_user.language)
|
||||
|
||||
if success:
|
||||
clear_rules_cache()
|
||||
|
||||
await message.reply(
|
||||
f"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"• Очищено правил: {stats['total_active']}\n"
|
||||
f"• Язык: {db_user.language}\n"
|
||||
f"• Выполнил: {db_user.full_name}\n\n"
|
||||
f"Теперь используются стандартные правила по умолчанию."
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
|
||||
else:
|
||||
await message.reply(
|
||||
"⚠️ <b>Нет правил для очистки</b>\n\n"
|
||||
"Активные правила не найдены."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил командой: {e}")
|
||||
await message.reply(
|
||||
"❌ <b>Ошибка при очистке правил</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}\n"
|
||||
"Попробуйте через админ-панель или повторите позже."
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def rules_stats_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if 'error' in stats:
|
||||
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
|
||||
return
|
||||
|
||||
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
|
||||
text += f"📋 <b>Общая информация:</b>\n"
|
||||
text += f"• Активных правил: {stats['total_active']}\n"
|
||||
text += f"• Всего в истории: {stats['total_all_time']}\n"
|
||||
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
|
||||
|
||||
if stats['languages']:
|
||||
text += f"🌐 <b>По языкам:</b>\n"
|
||||
for lang, lang_stats in stats['languages'].items():
|
||||
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
|
||||
text += f"{lang_stats['content_length']} символов\n"
|
||||
if lang_stats['last_updated']:
|
||||
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||
else:
|
||||
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
|
||||
|
||||
await message.reply(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении статистики правил: {e}")
|
||||
await message.reply(
|
||||
f"❌ <b>Ошибка получения статистики</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_commands_help(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = """
|
||||
🔧 <b>Доступные админские команды:</b>
|
||||
|
||||
<b>📋 Управление правилами:</b>
|
||||
• <code>/clear_rules</code> - очистить все правила
|
||||
• <code>/rules_stats</code> - статистика правил
|
||||
|
||||
<b>ℹ️ Справка:</b>
|
||||
• <code>/admin_help</code> - это сообщение
|
||||
|
||||
<b>📱 Панель управления:</b>
|
||||
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
Все команды логируются и требуют админских прав.
|
||||
"""
|
||||
|
||||
await message.reply(help_text)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
@@ -167,3 +293,18 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
Command("clear_rules")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
rules_stats_command,
|
||||
Command("rules_stats")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
+459
-23
@@ -13,7 +13,8 @@ from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -23,6 +24,71 @@ from app.utils.decorators import admin_required, error_handler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
button_row = []
|
||||
if "balance" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
|
||||
|
||||
if "referrals" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
|
||||
|
||||
if "promocode" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
|
||||
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[:2])
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[2:])
|
||||
elif button_row:
|
||||
keyboard.append(button_row)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
@@ -107,13 +173,16 @@ async def show_messages_history(
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
import html
|
||||
message_preview = html.escape(message_preview)
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
@@ -121,7 +190,7 @@ async def show_messages_history(
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -260,15 +329,289 @@ async def process_broadcast_message(
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
await message.answer(
|
||||
"🖼️ <b>Добавление медиафайла</b>\n\n"
|
||||
"Вы можете добавить к сообщению фото, видео или документ.\n"
|
||||
"Или пропустить этот шаг.\n\n"
|
||||
"Выберите тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data == "skip_media":
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_media(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
expected_type = data.get('media_type')
|
||||
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
|
||||
if message.photo and expected_type == "photo":
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_type = "photo"
|
||||
elif message.video and expected_type == "video":
|
||||
media_file_id = message.video.file_id
|
||||
media_type = "video"
|
||||
elif message.document and expected_type == "document":
|
||||
media_file_id = message.document.file_id
|
||||
media_type = "document"
|
||||
else:
|
||||
await message.answer(
|
||||
f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
has_media=True,
|
||||
media_file_id=media_file_id,
|
||||
media_type=media_type,
|
||||
media_caption=message.caption
|
||||
)
|
||||
|
||||
await show_media_preview(message, db_user, state)
|
||||
|
||||
async def show_media_preview(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type')
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
action = callback.data
|
||||
|
||||
if action == "confirm_media":
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
elif action == "replace_media":
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type', 'photo')
|
||||
await handle_media_selection(callback, db_user, state)
|
||||
elif action == "skip_media":
|
||||
await state.update_data(
|
||||
has_media=False,
|
||||
media_file_id=None,
|
||||
media_type=None,
|
||||
media_caption=None
|
||||
)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_change_media(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🖼️ <b>Изменение медиафайла</b>\n\n"
|
||||
"Выберите новый тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
has_media = data.get('has_media', False)
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
if 'selected_buttons' not in data:
|
||||
data['selected_buttons'] = []
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
button_type = callback.data.replace('btn_', '')
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
if button_type in selected_buttons:
|
||||
selected_buttons.remove(button_type)
|
||||
else:
|
||||
selected_buttons.append(button_type)
|
||||
|
||||
data['selected_buttons'] = selected_buttons
|
||||
await state.set_data(data)
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type_names = {
|
||||
"photo": "Фотография",
|
||||
"video": "Видео",
|
||||
"document": "Документ"
|
||||
}
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
buttons_info = ""
|
||||
if selected_buttons:
|
||||
buttons_list = []
|
||||
if "balance" in selected_buttons:
|
||||
buttons_list.append("💰 Пополнить баланс")
|
||||
if "referrals" in selected_buttons:
|
||||
buttons_list.append("🤝 Рефералы")
|
||||
if "promocode" in selected_buttons:
|
||||
buttons_list.append("🎫 Промокод")
|
||||
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
|
||||
|
||||
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
@@ -276,7 +619,9 @@ async def process_broadcast_message(
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
{message_text}{media_info}
|
||||
|
||||
{buttons_info}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
@@ -284,16 +629,59 @@ async def process_broadcast_message(
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -307,6 +695,11 @@ async def confirm_broadcast(
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
@@ -323,6 +716,10 @@ async def confirm_broadcast(
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
has_media=has_media,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
@@ -337,13 +734,42 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
@@ -359,6 +785,10 @@ async def confirm_broadcast(
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type}"
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
@@ -366,7 +796,7 @@ async def confirm_broadcast(
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
@@ -380,7 +810,7 @@ async def confirm_broadcast(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
@@ -413,7 +843,6 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -462,7 +891,6 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -557,4 +985,12 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
|
||||
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
|
||||
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
|
||||
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
|
||||
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
|
||||
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -19,7 +20,6 @@ router = Router()
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -127,50 +127,57 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@router.callback_query(F.data.startswith("admin_mon_logs"))
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
page = 1
|
||||
if "_page_" in callback.data:
|
||||
page = int(callback.data.split("_page_")[1])
|
||||
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
if not all_logs:
|
||||
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
|
||||
keyboard = get_monitoring_logs_back_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
return
|
||||
|
||||
per_page = 8
|
||||
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
|
||||
|
||||
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
|
||||
|
||||
for log in paginated_logs.items:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
message = log['message']
|
||||
if len(message) > 45:
|
||||
message = message[:45] + "..."
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
total_success = sum(1 for log in all_logs if log['is_success'])
|
||||
total_failed = len(all_logs) - total_success
|
||||
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
|
||||
|
||||
text += f"📊 <b>Общая статистика:</b>\n"
|
||||
text += f"• Всего событий: {len(all_logs)}\n"
|
||||
text += f"• Успешных: {total_success}\n"
|
||||
text += f"• Ошибок: {total_failed}\n"
|
||||
text += f"• Успешность: {success_rate}%"
|
||||
|
||||
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
await callback.answer("❌ Ошибка получения логов", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@@ -178,12 +185,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
await callback.answer("ℹ️ Логи уже пусты")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
@@ -196,7 +203,6 @@ async def clear_logs_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
@@ -280,10 +286,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_monitoring_logs_back_keyboard():
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -309,5 +367,4 @@ async def monitoring_command(message: Message):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
+223
-42
@@ -8,7 +8,8 @@ from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +32,9 @@ async def show_rules_management(
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -48,16 +51,33 @@ async def view_current_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
is_valid, error_msg = validate_html_tags(current_rules)
|
||||
warning = ""
|
||||
if not is_valid:
|
||||
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -68,20 +88,33 @@ async def start_edit_rules(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
|
||||
text = (
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
|
||||
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при начале редактирования правил: {e}")
|
||||
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -98,19 +131,61 @@ async def process_rules_edit(
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
|
||||
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
|
||||
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
try:
|
||||
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
preview_text += f"Сохранить изменения?"
|
||||
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
|
||||
f"{new_rules[:500]}...\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Текст правил: {len(new_rules)} символов\n"
|
||||
f"Сохранить изменения?"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе превью правил: {e}")
|
||||
await message.answer(
|
||||
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
|
||||
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
|
||||
f"HTML теги проверены и корректны.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -128,6 +203,20 @@ async def save_rules(
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
|
||||
f"Правила не были сохранены из-за ошибок в HTML разметке.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
@@ -142,10 +231,14 @@ async def save_rules(
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
|
||||
"✓ Новые правила сохранены в базе данных\n"
|
||||
"✓ HTML теги проверены и корректны\n"
|
||||
"✓ Кеш правил очищен и обновлен\n"
|
||||
"✓ Правила будут показываться пользователям\n\n"
|
||||
f"📊 Размер текста: {len(new_rules)} символов",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
@@ -156,7 +249,90 @@ async def save_rules(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Ошибка при сохранении правил</b>\n\n"
|
||||
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Очистка правил сервиса</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
|
||||
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
|
||||
"Это действие нельзя отменить. Продолжить?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_clear_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await clear_all_rules(db, db_user.language)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
"✓ Все пользовательские правила удалены\n"
|
||||
"✓ Теперь используются стандартные правила\n"
|
||||
"✓ Кеш правил очищен\n\n"
|
||||
"Пользователи будут видеть правила по умолчанию.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил: {e}")
|
||||
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = get_html_help_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"ℹ️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -165,4 +341,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
|
||||
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
|
||||
|
||||
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
@@ -1644,7 +1644,12 @@ async def toggle_user_server(
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
active_internal_squads=current_squads,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2023,7 +2028,12 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
hwid_device_limit=devices,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2061,7 +2071,12 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
|
||||
@@ -42,9 +42,10 @@ async def show_main_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -119,7 +120,8 @@ async def handle_back_to_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,16 +90,45 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
|
||||
+36
-5
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -147,7 +148,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -448,7 +450,8 @@ async def complete_registration_from_callback(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -563,7 +566,8 @@ async def complete_registration_from_callback(
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -611,7 +615,8 @@ async def complete_registration(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -726,7 +731,8 @@ async def complete_registration(
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -844,6 +850,25 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery,
|
||||
bot: Bot,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
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)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
@@ -882,6 +907,12 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
dp.callback_query.register(
|
||||
required_sub_channel_check,
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ async def apply_countries_changes(
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger.info(f"🔍 Применение изменений стран")
|
||||
logger.info(f"🔧 Применение изменений стран")
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -642,7 +642,7 @@ async def apply_countries_changes(
|
||||
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}")
|
||||
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
@@ -674,7 +674,7 @@ async def apply_countries_changes(
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
await callback.answer(
|
||||
f"⚠ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -686,7 +686,7 @@ async def apply_countries_changes(
|
||||
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
@@ -718,6 +718,15 @@ async def apply_countries_changes(
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
|
||||
|
||||
success_text = "✅ <b>Страны успешно обновлены!</b>\n\n"
|
||||
|
||||
if added_names:
|
||||
@@ -732,7 +741,7 @@ async def apply_countries_changes(
|
||||
success_text += "\n".join(f"• {name}" for name in removed_names)
|
||||
success_text += "\nℹ️ Повторное подключение будет платным\n"
|
||||
|
||||
success_text += f"\n🌍 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
success_text += f"\n🌐 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
@@ -744,7 +753,7 @@ async def apply_countries_changes(
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка применения изменений: {e}")
|
||||
logger.error(f"⚠️ Ошибка применения изменений: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -930,6 +939,15 @@ async def execute_change_devices(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "devices", current_devices, new_devices_count, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
|
||||
|
||||
if new_devices_count > current_devices:
|
||||
success_text = f"✅ Количество устройств увеличено!\n\n"
|
||||
success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n"
|
||||
@@ -3455,6 +3473,15 @@ async def execute_switch_traffic(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
|
||||
|
||||
if new_traffic_gb > current_traffic:
|
||||
success_text = f"✅ Лимит трафика увеличен!\n\n"
|
||||
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
|
||||
|
||||
+246
-15
@@ -8,20 +8,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Юзеры и Подписки", callback_data="admin_submenu_users"),
|
||||
InlineKeyboardButton(text="💰 Промокоды и статистика", callback_data="admin_submenu_promo")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📨 Коммуникации", callback_data="admin_submenu_communications"),
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🛠️ Системные функции", callback_data="admin_submenu_system")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
@@ -548,7 +540,7 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
@@ -570,6 +562,170 @@ def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_navigation_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
has_logs: bool = True
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page_info"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
management_row = []
|
||||
|
||||
if has_logs:
|
||||
management_row.extend([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
])
|
||||
else:
|
||||
management_row.append(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs")
|
||||
)
|
||||
|
||||
keyboard.append(management_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить этот лог",
|
||||
callback_data=f"admin_mon_delete_log_{log_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку логов",
|
||||
callback_data=f"admin_mon_logs_page_{current_page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_status_keyboard(
|
||||
is_running: bool,
|
||||
last_check_ago_minutes: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
control_row = []
|
||||
if is_running:
|
||||
control_row.extend([
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"),
|
||||
InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart")
|
||||
])
|
||||
else:
|
||||
control_row.append(
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start")
|
||||
)
|
||||
|
||||
keyboard.append(control_row)
|
||||
|
||||
monitoring_row = []
|
||||
|
||||
if not is_running or last_check_ago_minutes > 10:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⚡ Срочная проверка",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
else:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить сейчас",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(monitoring_row)
|
||||
|
||||
info_row = [
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
]
|
||||
keyboard.append(info_row)
|
||||
|
||||
test_row = [
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications")
|
||||
]
|
||||
keyboard.append(test_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"),
|
||||
InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"),
|
||||
InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_log_type_filter_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"),
|
||||
InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"),
|
||||
InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -756,3 +912,78 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"),
|
||||
InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"),
|
||||
InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"),
|
||||
InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
]
|
||||
]
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
+55
-7
@@ -21,6 +21,22 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(channel_link: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подписаться", url=channel_link
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался", callback_data="sub_channel_check"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -39,7 +55,8 @@ def get_main_menu_keyboard(
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -51,16 +68,47 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
callback_data="subscription_connect"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
|
||||
+41
-17
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
@@ -8,11 +9,31 @@ from aiogram.fsm.context import FSMContext
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _refresh_remnawave_description(
|
||||
remnawave_uuid: str,
|
||||
description: str,
|
||||
telegram_id: int
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(
|
||||
f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}"
|
||||
)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
@@ -21,6 +42,8 @@ class AuthMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
@@ -42,23 +65,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_registration_process:
|
||||
if is_reg_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
@@ -151,11 +161,25 @@ class AuthMiddleware(BaseMiddleware):
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=db_user.full_name,
|
||||
username=db_user.username,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
asyncio.create_task(
|
||||
_refresh_remnawave_description(
|
||||
remnawave_uuid=db_user.remnawave_uuid,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
|
||||
from app.config import settings
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = ("left", "kicked")
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id is None:
|
||||
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:
|
||||
return await handler(event, data)
|
||||
|
||||
bot: Bot = data["bot"]
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
if not channel_id:
|
||||
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:
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
# бот не админ или нет доступа к каналу
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
# если все каналы пройдены
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
@@ -707,4 +707,97 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
update_type: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
price_paid: int = 0
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
|
||||
message += f"""
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
return False
|
||||
|
||||
async def _format_servers_detailed(self, server_uuids: List[str]) -> str:
|
||||
if not server_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(server_uuids)
|
||||
|
||||
if servers_names and servers_names != "Нет серверов":
|
||||
return f"{len(server_uuids)} серверов ({servers_names})"
|
||||
else:
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения названий серверов для уведомления: {e}")
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
def _format_update_value(self, value: Any, update_type: str) -> str:
|
||||
if update_type == "traffic":
|
||||
if value == 0:
|
||||
return "♾ Безлимитный"
|
||||
return f"{value} ГБ"
|
||||
elif update_type == "devices":
|
||||
return f"{value} устройств"
|
||||
elif update_type == "servers":
|
||||
if isinstance(value, list):
|
||||
return f"{len(value)} серверов"
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -158,8 +158,13 @@ class MonitoringService:
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -693,7 +698,9 @@ class MonitoringService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
event_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
@@ -703,7 +710,11 @@ class MonitoringService:
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
if page > 1 or per_page != 20:
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
else:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
@@ -723,25 +734,54 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
return count or 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
if days == 0:
|
||||
result = await db.execute(delete(MonitoringLog))
|
||||
else:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
if days == 0:
|
||||
logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)")
|
||||
else:
|
||||
logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+144
-14
@@ -4,7 +4,7 @@ import hmac
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -12,10 +12,16 @@ from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,6 +124,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -126,7 +160,8 @@ class PaymentService:
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
@@ -309,6 +344,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -316,9 +379,12 @@ class PaymentService:
|
||||
f"🦊 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
@@ -382,20 +448,55 @@ class PaymentService:
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
message = (
|
||||
f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
@@ -641,6 +742,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -649,7 +778,8 @@ class PaymentService:
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -779,6 +779,11 @@ class RemnaWaveService:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
stats["updated"] += 1
|
||||
@@ -793,7 +798,11 @@ class RemnaWaveService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -82,7 +87,11 @@ class SubscriptionService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -135,8 +144,13 @@ class SubscriptionService:
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.transaction import (
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -218,7 +219,40 @@ class TributeService:
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
]
|
||||
)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
@@ -226,12 +260,7 @@ class TributeService:
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
|
||||
@@ -43,6 +43,7 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
@@ -67,6 +68,7 @@ class AdminStates(StatesGroup):
|
||||
creating_server_country = State()
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from app.states import RegistrationStates
|
||||
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
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
|
||||
+86
-18
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
@@ -14,6 +14,11 @@ ALLOWED_HTML_TAGS = {
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
SELF_CLOSING_TAGS = {
|
||||
'br', 'hr', 'img'
|
||||
}
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
@@ -158,7 +163,8 @@ def validate_referral_code(code: str) -> bool:
|
||||
|
||||
return validate_promocode(code)
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
|
||||
def validate_html_tags(text: str) -> Tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
@@ -168,21 +174,34 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS:
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS and tag_name_lower not in SELF_CLOSING_TAGS:
|
||||
return False, f"Неподдерживаемый тег: <{tag_name}>"
|
||||
|
||||
return validate_html_structure(text)
|
||||
|
||||
|
||||
def validate_html_structure(text: str) -> Tuple[bool, str]:
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?/?>'
|
||||
|
||||
matches = re.finditer(tag_pattern, text)
|
||||
tag_stack = []
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
for match in matches:
|
||||
full_tag = match.group(0)
|
||||
is_closing = bool(match.group(1))
|
||||
tag_name = match.group(2).lower()
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name_lower)
|
||||
else:
|
||||
if full_tag.endswith('/>') or tag_name in SELF_CLOSING_TAGS:
|
||||
continue
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name)
|
||||
else:
|
||||
if not tag_stack:
|
||||
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
|
||||
|
||||
last_tag = tag_stack.pop()
|
||||
if last_tag != tag_name_lower:
|
||||
if last_tag != tag_name:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
@@ -191,16 +210,65 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def fix_html_tags(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
fixes = [
|
||||
(r'<a href=([^"\s>]+)>', r'<a href="\1">'),
|
||||
(r'<(br|hr|img[^>]*?)>', r'<\1 />'),
|
||||
(r'<<([^>]+)>>', r'<\1>'),
|
||||
(r'<\s+([^>]+)\s+>', r'<\1>'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in fixes:
|
||||
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_html_help_text() -> str:
|
||||
return """<b>Поддерживаемые HTML теги:</b>
|
||||
|
||||
- <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
- <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
- <code><u>подчеркнутый</u></code>
|
||||
- <code><s>зачеркнутый</s></code>
|
||||
- <code><code>моноширинный</code></code>
|
||||
- <code><pre>блок кода</pre></code>
|
||||
- <code><a href="url">ссылка</a></code>
|
||||
- <code><blockquote>цитата</blockquote></code>
|
||||
• <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
• <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
• <code><u>подчеркнутый</u></code>
|
||||
• <code><s>зачеркнутый</s></code>
|
||||
• <code><code>моноширинный</code></code>
|
||||
• <code><pre>блок кода</pre></code>
|
||||
• <code><a href="url">ссылка</a></code>
|
||||
• <code><blockquote>цитата</blockquote></code>
|
||||
|
||||
<b>Неподдерживаемые теги:</b> <br>, <p>, <div>, <span>, <spoiler> и другие"""
|
||||
<b>⚠️ Важные правила:</b>
|
||||
• Каждый открывающий тег должен быть закрыт
|
||||
• Теги должны быть правильно вложены
|
||||
• Атрибуты ссылок берите в кавычки
|
||||
|
||||
<b>❌ Неправильно:</b>
|
||||
<code><b>жирный <i>курсив</b></i></code>
|
||||
<code><a href=google.com>ссылка</a></code>
|
||||
|
||||
<b>✅ Правильно:</b>
|
||||
<code><b>жирный <i>курсив</i></b></code>
|
||||
<code><a href="https://google.com">ссылка</a></code>"""
|
||||
|
||||
|
||||
def validate_rules_content(text: str) -> Tuple[bool, str, Optional[str]]:
|
||||
if not text or not text.strip():
|
||||
return False, "Текст правил не может быть пустым", None
|
||||
|
||||
if len(text) > 4000:
|
||||
return False, f"Текст слишком длинный: {len(text)} символов (максимум 4000)", None
|
||||
|
||||
is_valid_html, html_error = validate_html_tags(text)
|
||||
if not is_valid_html:
|
||||
fixed_text = fix_html_tags(text)
|
||||
fixed_is_valid, _ = validate_html_tags(fixed_text)
|
||||
|
||||
if fixed_is_valid and fixed_text != text:
|
||||
return False, html_error, fixed_text
|
||||
else:
|
||||
return False, html_error, None
|
||||
|
||||
return True, "", None
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 716 KiB |
Reference in New Issue
Block a user