diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py
new file mode 100644
index 00000000..eaa789ae
--- /dev/null
+++ b/app/database/crud/discount_offer.py
@@ -0,0 +1,90 @@
+from datetime import datetime, timedelta
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.models import DiscountOffer
+
+
+async def upsert_discount_offer(
+ db: AsyncSession,
+ *,
+ user_id: int,
+ subscription_id: Optional[int],
+ notification_type: str,
+ discount_percent: int,
+ bonus_amount_kopeks: int,
+ valid_hours: int,
+) -> DiscountOffer:
+ """Create or refresh a discount offer for a user."""
+
+ expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
+
+ result = await db.execute(
+ select(DiscountOffer)
+ .where(
+ DiscountOffer.user_id == user_id,
+ DiscountOffer.notification_type == notification_type,
+ DiscountOffer.is_active == True, # noqa: E712
+ )
+ .order_by(DiscountOffer.created_at.desc())
+ )
+ offer = result.scalars().first()
+
+ if offer and offer.claimed_at is None:
+ offer.discount_percent = discount_percent
+ offer.bonus_amount_kopeks = bonus_amount_kopeks
+ offer.expires_at = expires_at
+ offer.subscription_id = subscription_id
+ else:
+ offer = DiscountOffer(
+ user_id=user_id,
+ subscription_id=subscription_id,
+ notification_type=notification_type,
+ discount_percent=discount_percent,
+ bonus_amount_kopeks=bonus_amount_kopeks,
+ expires_at=expires_at,
+ is_active=True,
+ )
+ db.add(offer)
+
+ await db.commit()
+ await db.refresh(offer)
+ return offer
+
+
+async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
+ result = await db.execute(
+ select(DiscountOffer).where(DiscountOffer.id == offer_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
+ offer.claimed_at = datetime.utcnow()
+ offer.is_active = False
+ await db.commit()
+ await db.refresh(offer)
+ return offer
+
+
+async def deactivate_expired_offers(db: AsyncSession) -> int:
+ now = datetime.utcnow()
+ result = await db.execute(
+ select(DiscountOffer).where(
+ DiscountOffer.is_active == True, # noqa: E712
+ DiscountOffer.expires_at < now,
+ )
+ )
+ offers = result.scalars().all()
+ if not offers:
+ return 0
+
+ count = 0
+ for offer in offers:
+ offer.is_active = False
+ count += 1
+
+ await db.commit()
+ return count
diff --git a/app/database/models.py b/app/database/models.py
index f9b6d8ab..91a7a360 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -14,6 +14,7 @@ from sqlalchemy import (
JSON,
BigInteger,
UniqueConstraint,
+ Index,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -358,6 +359,7 @@ class User(Base):
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
+ discount_offers = relationship("DiscountOffer", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
last_remnawave_sync = Column(DateTime, nullable=True)
@@ -420,8 +422,9 @@ class Subscription(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
remnawave_short_uuid = Column(String(255), nullable=True)
-
+
user = relationship("User", back_populates="subscription")
+ discount_offers = relationship("DiscountOffer", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -765,6 +768,28 @@ class SentNotification(Base):
user = relationship("User", backref="sent_notifications")
subscription = relationship("Subscription", backref="sent_notifications")
+
+class DiscountOffer(Base):
+ __tablename__ = "discount_offers"
+ __table_args__ = (
+ Index("ix_discount_offers_user_type", "user_id", "notification_type"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True)
+ notification_type = Column(String(50), nullable=False)
+ discount_percent = Column(Integer, nullable=False, default=0)
+ bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
+ expires_at = Column(DateTime, nullable=False)
+ claimed_at = Column(DateTime, nullable=True)
+ is_active = Column(Boolean, default=True, nullable=False)
+ created_at = Column(DateTime, default=func.now())
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+
+ user = relationship("User", back_populates="discount_offers")
+ subscription = relationship("Subscription", back_populates="discount_offers")
+
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 40273ff4..522747f0 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -520,6 +520,94 @@ async def create_pal24_payments_table():
logger.error(f"Ошибка создания таблицы pal24_payments: {e}")
return False
+
+async def create_discount_offers_table():
+ table_exists = await check_table_exists('discount_offers')
+ if table_exists:
+ logger.info("Таблица discount_offers уже существует")
+ return True
+
+ try:
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == 'sqlite':
+ await conn.execute(text("""
+ CREATE TABLE discount_offers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ subscription_id INTEGER NULL,
+ notification_type VARCHAR(50) NOT NULL,
+ discount_percent INTEGER NOT NULL DEFAULT 0,
+ bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
+ expires_at DATETIME NOT NULL,
+ claimed_at DATETIME NULL,
+ is_active BOOLEAN NOT NULL DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL
+ )
+ """))
+ await conn.execute(text("""
+ CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type
+ ON discount_offers (user_id, notification_type)
+ """))
+
+ elif db_type == 'postgresql':
+ await conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS discount_offers (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
+ notification_type VARCHAR(50) NOT NULL,
+ discount_percent INTEGER NOT NULL DEFAULT 0,
+ bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
+ expires_at TIMESTAMP NOT NULL,
+ claimed_at TIMESTAMP NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """))
+ await conn.execute(text("""
+ CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type
+ ON discount_offers (user_id, notification_type)
+ """))
+
+ elif db_type == 'mysql':
+ await conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS discount_offers (
+ id INTEGER PRIMARY KEY AUTO_INCREMENT,
+ user_id INTEGER NOT NULL,
+ subscription_id INTEGER NULL,
+ notification_type VARCHAR(50) NOT NULL,
+ discount_percent INTEGER NOT NULL DEFAULT 0,
+ bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
+ expires_at DATETIME NOT NULL,
+ claimed_at DATETIME NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL
+ )
+ """))
+ await conn.execute(text("""
+ CREATE INDEX ix_discount_offers_user_type
+ ON discount_offers (user_id, notification_type)
+ """))
+
+ else:
+ raise ValueError(f"Unsupported database type: {db_type}")
+
+ logger.info("✅ Таблица discount_offers успешно создана")
+ return True
+
+ except Exception as e:
+ logger.error(f"Ошибка создания таблицы discount_offers: {e}")
+ return False
+
async def create_user_messages_table():
table_exists = await check_table_exists('user_messages')
if table_exists:
@@ -1467,6 +1555,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
+ logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
+ discount_created = await create_discount_offers_table()
+ if discount_created:
+ logger.info("✅ Таблица discount_offers готова")
+ else:
+ logger.warning("⚠️ Проблемы с таблицей discount_offers")
+
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
user_messages_created = await create_user_messages_table()
if user_messages_created:
diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py
index be876876..29d097d2 100644
--- a/app/handlers/admin/monitoring.py
+++ b/app/handlers/admin/monitoring.py
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
from app.config import settings
from app.database.database import get_db
@@ -12,11 +13,77 @@ 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
+from app.services.notification_settings_service import NotificationSettingsService
+from app.states import AdminStates
logger = logging.getLogger(__name__)
router = Router()
+def _format_toggle(enabled: bool) -> str:
+ return "🟢 Вкл" if enabled else "🔴 Выкл"
+
+
+def _build_notification_settings_view(language: str):
+ texts = get_texts(language)
+ config = NotificationSettingsService.get_config()
+
+ second_percent = NotificationSettingsService.get_second_wave_discount_percent()
+ second_hours = NotificationSettingsService.get_second_wave_valid_hours()
+ third_percent = NotificationSettingsService.get_third_wave_discount_percent()
+ third_hours = NotificationSettingsService.get_third_wave_valid_hours()
+ third_days = NotificationSettingsService.get_third_wave_trigger_days()
+
+ trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
+ trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
+ expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
+ second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
+ third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
+
+ summary_text = (
+ "🔔 Уведомления пользователям\n\n"
+ f"• 1 час после триала: {trial_1h_status}\n"
+ f"• 24 часа после триала: {trial_24h_status}\n"
+ f"• 1 день после истечения: {expired_1d_status}\n"
+ f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
+ f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
+ )
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
+ [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
+ [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
+ [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
+ [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
+ [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
+ [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
+ [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
+ [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
+ [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
+ [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
+ ])
+
+ return summary_text, keyboard
+
+
+async def _render_notification_settings(callback: CallbackQuery) -> None:
+ language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
+ text, keyboard = _build_notification_settings_view(language)
+ await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
+
+
+async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None:
+ text, keyboard = _build_notification_settings_view(language)
+ await bot.edit_message_text(
+ text,
+ chat_id,
+ message_id,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+
@router.callback_query(F.data == "admin_monitoring")
@admin_required
async def admin_monitoring_menu(callback: CallbackQuery):
@@ -52,6 +119,180 @@ async def admin_monitoring_menu(callback: CallbackQuery):
await callback.answer("❌ Ошибка получения данных", show_alert=True)
+@router.callback_query(F.data == "admin_mon_settings")
+@admin_required
+async def admin_monitoring_settings(callback: CallbackQuery):
+ try:
+ language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
+ global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
+ second_percent = NotificationSettingsService.get_second_wave_discount_percent()
+ third_percent = NotificationSettingsService.get_third_wave_discount_percent()
+ third_days = NotificationSettingsService.get_third_wave_trigger_days()
+
+ text = (
+ "⚙️ Настройки мониторинга\n\n"
+ f"🔔 Уведомления пользователям: {global_status}\n"
+ f"• Скидка 2-3 дня: {second_percent}%\n"
+ f"• Скидка после {third_days} дней: {third_percent}%\n\n"
+ "Выберите раздел для настройки."
+ )
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
+ [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
+ ])
+
+ await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f"Ошибка отображения настроек мониторинга: {e}")
+ await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
+
+
+@router.callback_query(F.data == "admin_mon_notify_settings")
+@admin_required
+async def admin_notify_settings(callback: CallbackQuery):
+ try:
+ await _render_notification_settings(callback)
+ except Exception as e:
+ logger.error(f"Ошибка отображения настроек уведомлений: {e}")
+ await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
+
+
+@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
+@admin_required
+async def toggle_trial_1h_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
+ NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
+@admin_required
+async def toggle_trial_24h_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
+ NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
+@admin_required
+async def toggle_expired_1d_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_expired_1d_enabled()
+ NotificationSettingsService.set_expired_1d_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
+@admin_required
+async def toggle_second_wave_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_second_wave_enabled()
+ NotificationSettingsService.set_second_wave_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
+@admin_required
+async def toggle_third_wave_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_third_wave_enabled()
+ NotificationSettingsService.set_third_wave_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+async def _start_notification_value_edit(
+ callback: CallbackQuery,
+ state: FSMContext,
+ setting_key: str,
+ field: str,
+ prompt_key: str,
+ default_prompt: str,
+):
+ language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
+ await state.set_state(AdminStates.editing_notification_value)
+ await state.update_data(
+ notification_setting_key=setting_key,
+ notification_setting_field=field,
+ settings_message_chat=callback.message.chat.id,
+ settings_message_id=callback.message.message_id,
+ settings_language=language,
+ )
+ texts = get_texts(language)
+ await callback.answer()
+ await callback.message.answer(texts.get(prompt_key, default_prompt))
+
+
+@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
+@admin_required
+async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
+ await _start_notification_value_edit(
+ callback,
+ state,
+ "expired_second_wave",
+ "percent",
+ "NOTIFY_PROMPT_SECOND_PERCENT",
+ "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
+ )
+
+
+@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
+@admin_required
+async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
+ await _start_notification_value_edit(
+ callback,
+ state,
+ "expired_second_wave",
+ "hours",
+ "NOTIFY_PROMPT_SECOND_HOURS",
+ "Введите количество часов действия скидки (1-168):",
+ )
+
+
+@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
+@admin_required
+async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
+ await _start_notification_value_edit(
+ callback,
+ state,
+ "expired_third_wave",
+ "percent",
+ "NOTIFY_PROMPT_THIRD_PERCENT",
+ "Введите новый процент скидки для позднего предложения (0-100):",
+ )
+
+
+@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
+@admin_required
+async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
+ await _start_notification_value_edit(
+ callback,
+ state,
+ "expired_third_wave",
+ "hours",
+ "NOTIFY_PROMPT_THIRD_HOURS",
+ "Введите количество часов действия скидки (1-168):",
+ )
+
+
+@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
+@admin_required
+async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
+ await _start_notification_value_edit(
+ callback,
+ state,
+ "expired_third_wave",
+ "trigger",
+ "NOTIFY_PROMPT_THIRD_DAYS",
+ "Через сколько дней после истечения отправлять предложение? (минимум 2):",
+ )
+
+
@router.callback_query(F.data == "admin_mon_start")
@admin_required
async def start_monitoring_callback(callback: CallbackQuery):
@@ -366,5 +607,53 @@ async def monitoring_command(message: Message):
await message.answer(f"❌ Ошибка: {str(e)}")
+@router.message(AdminStates.editing_notification_value)
+async def process_notification_value_input(message: Message, state: FSMContext):
+ data = await state.get_data()
+ if not data:
+ await state.clear()
+ await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
+ return
+
+ raw_value = (message.text or "").strip()
+ try:
+ value = int(raw_value)
+ except (TypeError, ValueError):
+ language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
+ texts = get_texts(language)
+ await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
+ return
+
+ key = data.get("notification_setting_key")
+ field = data.get("notification_setting_field")
+ language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
+ texts = get_texts(language)
+
+ success = False
+ if key == "expired_second_wave" and field == "percent":
+ success = NotificationSettingsService.set_second_wave_discount_percent(value)
+ elif key == "expired_second_wave" and field == "hours":
+ success = NotificationSettingsService.set_second_wave_valid_hours(value)
+ elif key == "expired_third_wave" and field == "percent":
+ success = NotificationSettingsService.set_third_wave_discount_percent(value)
+ elif key == "expired_third_wave" and field == "hours":
+ success = NotificationSettingsService.set_third_wave_valid_hours(value)
+ elif key == "expired_third_wave" and field == "trigger":
+ success = NotificationSettingsService.set_third_wave_trigger_days(value)
+
+ if not success:
+ await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
+ return
+
+ await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."))
+
+ chat_id = data.get("settings_message_chat")
+ message_id = data.get("settings_message_id")
+ if chat_id and message_id:
+ await _render_notification_settings_for_state(message.bot, chat_id, message_id, language)
+
+ await state.clear()
+
+
def register_handlers(dp):
dp.include_router(router)
\ No newline at end of file
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 3f0c182a..3eeee497 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -17,12 +17,13 @@ from app.database.crud.subscription import (
add_subscription_squad, update_subscription_autopay,
add_subscription_servers
)
-from app.database.crud.user import subtract_user_balance
+from app.database.crud.user import subtract_user_balance, add_user_balance
from app.database.crud.transaction import create_transaction, get_user_transactions
from app.database.models import (
- User, TransactionType, SubscriptionStatus,
- SubscriptionServer, Subscription
+ User, TransactionType, SubscriptionStatus,
+ SubscriptionServer, Subscription
)
+from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
@@ -4068,6 +4069,76 @@ async def handle_connect_subscription(
await callback.answer()
+async def claim_discount_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ try:
+ offer_id = int(callback.data.split("_")[-1])
+ except (ValueError, AttributeError):
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
+ show_alert=True,
+ )
+ return
+
+ offer = await get_offer_by_id(db, offer_id)
+ if not offer or offer.user_id != db_user.id:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
+ show_alert=True,
+ )
+ return
+
+ now = datetime.utcnow()
+ if offer.claimed_at is not None:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
+ show_alert=True,
+ )
+ return
+
+ if not offer.is_active or offer.expires_at <= now:
+ offer.is_active = False
+ await db.commit()
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
+ show_alert=True,
+ )
+ return
+
+ bonus_amount = offer.bonus_amount_kopeks or 0
+ if bonus_amount > 0:
+ success = await add_user_balance(
+ db,
+ db_user,
+ bonus_amount,
+ texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"),
+ )
+ if not success:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."),
+ show_alert=True,
+ )
+ return
+
+ await mark_offer_claimed(db, offer)
+
+ success_message = texts.get(
+ "DISCOUNT_CLAIM_SUCCESS",
+ "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
+ ).format(
+ percent=offer.discount_percent,
+ amount=settings.format_price(bonus_amount),
+ )
+
+ await callback.answer("✅ Скидка активирована!", show_alert=True)
+ await callback.message.answer(success_message)
+
+
async def handle_device_guide(
callback: types.CallbackQuery,
db_user: User,
@@ -4963,6 +5034,11 @@ def register_handlers(dp: Dispatcher):
F.data == "countries_apply"
)
+ dp.callback_query.register(
+ claim_discount_offer,
+ F.data.startswith("claim_discount_")
+ )
+
dp.callback_query.register(
handle_connect_subscription,
F.data == "subscription_connect"
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 8219147b..13c37204 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -99,6 +99,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
],
+ [
+ InlineKeyboardButton(
+ text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"),
+ callback_data="admin_mon_settings"
+ )
+ ],
[
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index a190aec4..337e18f8 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -21,10 +21,15 @@ from app.database.crud.notification import (
notification_sent,
record_notification,
)
+from app.database.crud.discount_offer import (
+ upsert_discount_offer,
+ deactivate_expired_offers,
+)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
+from app.services.notification_settings_service import NotificationSettingsService
from app.external.remnawave_api import (
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
@@ -80,10 +85,16 @@ class MonitoringService:
async for db in get_db():
try:
await self._cleanup_notification_cache()
-
+
+ expired_offers = await deactivate_expired_offers(db)
+ if expired_offers:
+ logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
+
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
- await self._check_trial_expiring_soon(db)
+ await self._check_trial_expiring_soon(db)
+ await self._check_trial_inactivity_notifications(db)
+ await self._check_expired_subscription_followups(db)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
@@ -250,7 +261,7 @@ class MonitoringService:
async def _check_trial_expiring_soon(self, db: AsyncSession):
try:
threshold_time = datetime.utcnow() + timedelta(hours=2)
-
+
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
@@ -288,7 +299,202 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
-
+
+ async def _check_trial_inactivity_notifications(self, db: AsyncSession):
+ if not NotificationSettingsService.are_notifications_globally_enabled():
+ return
+ if not self.bot:
+ return
+
+ try:
+ now = datetime.utcnow()
+ one_hour_ago = now - timedelta(hours=1)
+
+ result = await db.execute(
+ select(Subscription)
+ .options(selectinload(Subscription.user))
+ .where(
+ and_(
+ Subscription.status == SubscriptionStatus.ACTIVE.value,
+ Subscription.is_trial == True,
+ Subscription.start_date.isnot(None),
+ Subscription.start_date <= one_hour_ago,
+ Subscription.end_date > now,
+ )
+ )
+ )
+
+ subscriptions = result.scalars().all()
+ sent_1h = 0
+ sent_24h = 0
+
+ for subscription in subscriptions:
+ user = subscription.user
+ if not user:
+ continue
+
+ if (subscription.traffic_used_gb or 0) > 0:
+ continue
+
+ start_date = subscription.start_date
+ if not start_date:
+ continue
+
+ time_since_start = now - start_date
+
+ if (NotificationSettingsService.is_trial_inactive_1h_enabled()
+ and timedelta(hours=1) <= time_since_start < timedelta(hours=24)):
+ if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"):
+ success = await self._send_trial_inactive_notification(user, subscription, 1)
+ if success:
+ await record_notification(db, user.id, subscription.id, "trial_inactive_1h")
+ sent_1h += 1
+
+ if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24):
+ if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"):
+ success = await self._send_trial_inactive_notification(user, subscription, 24)
+ if success:
+ await record_notification(db, user.id, subscription.id, "trial_inactive_24h")
+ sent_24h += 1
+
+ if sent_1h or sent_24h:
+ await self._log_monitoring_event(
+ db,
+ "trial_inactivity_notifications",
+ f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа",
+ {"sent_1h": sent_1h, "sent_24h": sent_24h},
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}")
+
+ async def _check_expired_subscription_followups(self, db: AsyncSession):
+ if not NotificationSettingsService.are_notifications_globally_enabled():
+ return
+ if not self.bot:
+ return
+
+ try:
+ now = datetime.utcnow()
+
+ result = await db.execute(
+ select(Subscription)
+ .options(selectinload(Subscription.user))
+ .where(
+ and_(
+ Subscription.is_trial == False,
+ Subscription.end_date <= now,
+ )
+ )
+ )
+
+ subscriptions = result.scalars().all()
+ sent_day1 = 0
+ sent_wave2 = 0
+ sent_wave3 = 0
+
+ for subscription in subscriptions:
+ user = subscription.user
+ if not user:
+ continue
+
+ if subscription.end_date is None:
+ continue
+
+ time_since_end = now - subscription.end_date
+ if time_since_end.total_seconds() < 0:
+ continue
+
+ days_since = time_since_end.total_seconds() / 86400
+
+ # Day 1 reminder
+ if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
+ if not await notification_sent(db, user.id, subscription.id, "expired_1d"):
+ success = await self._send_expired_day1_notification(user, subscription)
+ if success:
+ await record_notification(db, user.id, subscription.id, "expired_1d")
+ sent_day1 += 1
+
+ # Second wave (2-3 days) discount
+ if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4:
+ if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
+ percent = NotificationSettingsService.get_second_wave_discount_percent()
+ valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
+ bonus_amount = settings.PRICE_30_DAYS * percent // 100
+ offer = await upsert_discount_offer(
+ db,
+ user_id=user.id,
+ subscription_id=subscription.id,
+ notification_type="expired_discount_wave2",
+ discount_percent=percent,
+ bonus_amount_kopeks=bonus_amount,
+ valid_hours=valid_hours,
+ )
+ success = await self._send_expired_discount_notification(
+ user,
+ subscription,
+ percent,
+ offer.expires_at,
+ offer.id,
+ "second",
+ bonus_amount,
+ )
+ if success:
+ await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
+ sent_wave2 += 1
+
+ # Third wave (N days) discount
+ if NotificationSettingsService.is_third_wave_enabled():
+ trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
+ if trigger_days <= days_since < trigger_days + 1:
+ if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
+ percent = NotificationSettingsService.get_third_wave_discount_percent()
+ valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
+ bonus_amount = settings.PRICE_30_DAYS * percent // 100
+ offer = await upsert_discount_offer(
+ db,
+ user_id=user.id,
+ subscription_id=subscription.id,
+ notification_type="expired_discount_wave3",
+ discount_percent=percent,
+ bonus_amount_kopeks=bonus_amount,
+ valid_hours=valid_hours,
+ )
+ success = await self._send_expired_discount_notification(
+ user,
+ subscription,
+ percent,
+ offer.expires_at,
+ offer.id,
+ "third",
+ bonus_amount,
+ trigger_days=trigger_days,
+ )
+ if success:
+ await record_notification(db, user.id, subscription.id, "expired_discount_wave3")
+ sent_wave3 += 1
+
+ if sent_day1 or sent_wave2 or sent_wave3:
+ await self._log_monitoring_event(
+ db,
+ "expired_followups_sent",
+ (
+ "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format(
+ sent_day1,
+ sent_wave2,
+ sent_wave3,
+ )
+ ),
+ {
+ "day1": sent_day1,
+ "wave2": sent_wave2,
+ "wave3": sent_wave3,
+ },
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}")
+
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
current_time = datetime.utcnow()
threshold_date = current_time + timedelta(days=days_before)
@@ -465,7 +671,7 @@ class MonitoringService:
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
-
+
message = f"""
🎁 Тестовая подписка скоро закончится!
@@ -501,7 +707,149 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
return False
-
+
+ async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
+ try:
+ texts = get_texts(user.language)
+ if hours >= 24:
+ template = texts.get(
+ "TRIAL_INACTIVE_24H",
+ (
+ "⏳ Вы ещё не подключились к VPN\n\n"
+ "Прошли сутки с активации тестового периода, но трафик не зафиксирован."
+ "\n\nНажмите кнопку ниже, чтобы подключиться."
+ ),
+ )
+ else:
+ template = texts.get(
+ "TRIAL_INACTIVE_1H",
+ (
+ "⏳ Прошёл час, а подключения нет\n\n"
+ "Если возникли сложности с запуском — воспользуйтесь инструкциями."
+ ),
+ )
+
+ message = template.format(
+ price=settings.format_price(settings.PRICE_30_DAYS),
+ end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ )
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
+ [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")],
+ [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
+ ])
+
+ await self.bot.send_message(
+ user.telegram_id,
+ message,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ return True
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}")
+ return False
+
+ async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
+ try:
+ texts = get_texts(user.language)
+ template = texts.get(
+ "SUBSCRIPTION_EXPIRED_1D",
+ (
+ "⛔ Подписка закончилась\n\n"
+ "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
+ ),
+ )
+ message = template.format(
+ end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ price=settings.format_price(settings.PRICE_30_DAYS),
+ )
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
+ [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
+ [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
+ ])
+
+ await self.bot.send_message(
+ user.telegram_id,
+ message,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ return True
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}")
+ return False
+
+ async def _send_expired_discount_notification(
+ self,
+ user: User,
+ subscription: Subscription,
+ percent: int,
+ expires_at: datetime,
+ offer_id: int,
+ wave: str,
+ bonus_amount: int,
+ trigger_days: int = None,
+ ) -> bool:
+ try:
+ texts = get_texts(user.language)
+
+ if wave == "second":
+ template = texts.get(
+ "SUBSCRIPTION_EXPIRED_SECOND_WAVE",
+ (
+ "🔥 Скидка {percent}% на продление\n\n"
+ "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
+ "Предложение действует до {expires_at}."
+ ),
+ )
+ else:
+ template = texts.get(
+ "SUBSCRIPTION_EXPIRED_THIRD_WAVE",
+ (
+ "🎁 Индивидуальная скидка {percent}%\n\n"
+ "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
+ "Скидка действует до {expires_at}."
+ ),
+ )
+
+ message = template.format(
+ percent=percent,
+ bonus=settings.format_price(bonus_amount),
+ expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
+ trigger_days=trigger_days or "",
+ )
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
+ [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
+ [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
+ [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
+ ])
+
+ await self.bot.send_message(
+ user.telegram_id,
+ message,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ return True
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}")
+ return False
+
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
try:
texts = get_texts(user.language)
diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py
new file mode 100644
index 00000000..a19edffd
--- /dev/null
+++ b/app/services/notification_settings_service.py
@@ -0,0 +1,249 @@
+import json
+import json
+import logging
+from copy import deepcopy
+from pathlib import Path
+from typing import Any, Dict
+
+from app.config import settings
+
+
+logger = logging.getLogger(__name__)
+
+
+class NotificationSettingsService:
+ """Runtime-editable notification settings stored on disk."""
+
+ _storage_path: Path = Path("data/notification_settings.json")
+ _data: Dict[str, Dict[str, Any]] = {}
+ _loaded: bool = False
+
+ _DEFAULTS: Dict[str, Dict[str, Any]] = {
+ "trial_inactive_1h": {"enabled": True},
+ "trial_inactive_24h": {"enabled": True},
+ "expired_1d": {"enabled": True},
+ "expired_second_wave": {
+ "enabled": True,
+ "discount_percent": 10,
+ "valid_hours": 24,
+ },
+ "expired_third_wave": {
+ "enabled": True,
+ "discount_percent": 20,
+ "valid_hours": 24,
+ "trigger_days": 5,
+ },
+ }
+
+ @classmethod
+ def _ensure_dir(cls) -> None:
+ try:
+ cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
+ except Exception as exc: # pragma: no cover - filesystem guard
+ logger.error("Failed to create notification settings dir: %s", exc)
+
+ @classmethod
+ def _load(cls) -> None:
+ if cls._loaded:
+ return
+
+ cls._ensure_dir()
+ try:
+ if cls._storage_path.exists():
+ raw = cls._storage_path.read_text(encoding="utf-8")
+ cls._data = json.loads(raw) if raw.strip() else {}
+ else:
+ cls._data = {}
+ except Exception as exc:
+ logger.error("Failed to load notification settings: %s", exc)
+ cls._data = {}
+
+ changed = cls._apply_defaults()
+ if changed:
+ cls._save()
+ cls._loaded = True
+
+ @classmethod
+ def _apply_defaults(cls) -> bool:
+ changed = False
+ for key, defaults in cls._DEFAULTS.items():
+ current = cls._data.get(key)
+ if not isinstance(current, dict):
+ cls._data[key] = deepcopy(defaults)
+ changed = True
+ continue
+
+ for def_key, def_value in defaults.items():
+ if def_key not in current:
+ current[def_key] = def_value
+ changed = True
+ return changed
+
+ @classmethod
+ def _save(cls) -> bool:
+ cls._ensure_dir()
+ try:
+ cls._storage_path.write_text(
+ json.dumps(cls._data, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ return True
+ except Exception as exc:
+ logger.error("Failed to save notification settings: %s", exc)
+ return False
+
+ @classmethod
+ def _get(cls, key: str) -> Dict[str, Any]:
+ cls._load()
+ value = cls._data.get(key)
+ if not isinstance(value, dict):
+ value = deepcopy(cls._DEFAULTS.get(key, {}))
+ cls._data[key] = value
+ return value
+
+ @classmethod
+ def get_config(cls) -> Dict[str, Dict[str, Any]]:
+ cls._load()
+ return deepcopy(cls._data)
+
+ @classmethod
+ def _set_field(cls, key: str, field: str, value: Any) -> bool:
+ cls._load()
+ section = cls._get(key)
+ section[field] = value
+ cls._data[key] = section
+ return cls._save()
+
+ @classmethod
+ def set_enabled(cls, key: str, enabled: bool) -> bool:
+ return cls._set_field(key, "enabled", bool(enabled))
+
+ @classmethod
+ def is_enabled(cls, key: str) -> bool:
+ return bool(cls._get(key).get("enabled", True))
+
+ # Trial inactivity helpers
+ @classmethod
+ def is_trial_inactive_1h_enabled(cls) -> bool:
+ return cls.is_enabled("trial_inactive_1h")
+
+ @classmethod
+ def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("trial_inactive_1h", enabled)
+
+ @classmethod
+ def is_trial_inactive_24h_enabled(cls) -> bool:
+ return cls.is_enabled("trial_inactive_24h")
+
+ @classmethod
+ def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("trial_inactive_24h", enabled)
+
+ # Expired subscription notifications
+ @classmethod
+ def is_expired_1d_enabled(cls) -> bool:
+ return cls.is_enabled("expired_1d")
+
+ @classmethod
+ def set_expired_1d_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("expired_1d", enabled)
+
+ @classmethod
+ def is_second_wave_enabled(cls) -> bool:
+ return cls.is_enabled("expired_second_wave")
+
+ @classmethod
+ def set_second_wave_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("expired_second_wave", enabled)
+
+ @classmethod
+ def get_second_wave_discount_percent(cls) -> int:
+ value = cls._get("expired_second_wave").get("discount_percent", 10)
+ try:
+ return max(0, min(100, int(value)))
+ except (TypeError, ValueError):
+ return 10
+
+ @classmethod
+ def set_second_wave_discount_percent(cls, percent: int) -> bool:
+ try:
+ percent_int = max(0, min(100, int(percent)))
+ except (TypeError, ValueError):
+ return False
+ return cls._set_field("expired_second_wave", "discount_percent", percent_int)
+
+ @classmethod
+ def get_second_wave_valid_hours(cls) -> int:
+ value = cls._get("expired_second_wave").get("valid_hours", 24)
+ try:
+ return max(1, min(168, int(value)))
+ except (TypeError, ValueError):
+ return 24
+
+ @classmethod
+ def set_second_wave_valid_hours(cls, hours: int) -> bool:
+ try:
+ hours_int = max(1, min(168, int(hours)))
+ except (TypeError, ValueError):
+ return False
+ return cls._set_field("expired_second_wave", "valid_hours", hours_int)
+
+ @classmethod
+ def is_third_wave_enabled(cls) -> bool:
+ return cls.is_enabled("expired_third_wave")
+
+ @classmethod
+ def set_third_wave_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("expired_third_wave", enabled)
+
+ @classmethod
+ def get_third_wave_discount_percent(cls) -> int:
+ value = cls._get("expired_third_wave").get("discount_percent", 20)
+ try:
+ return max(0, min(100, int(value)))
+ except (TypeError, ValueError):
+ return 20
+
+ @classmethod
+ def set_third_wave_discount_percent(cls, percent: int) -> bool:
+ try:
+ percent_int = max(0, min(100, int(percent)))
+ except (TypeError, ValueError):
+ return False
+ return cls._set_field("expired_third_wave", "discount_percent", percent_int)
+
+ @classmethod
+ def get_third_wave_valid_hours(cls) -> int:
+ value = cls._get("expired_third_wave").get("valid_hours", 24)
+ try:
+ return max(1, min(168, int(value)))
+ except (TypeError, ValueError):
+ return 24
+
+ @classmethod
+ def set_third_wave_valid_hours(cls, hours: int) -> bool:
+ try:
+ hours_int = max(1, min(168, int(hours)))
+ except (TypeError, ValueError):
+ return False
+ return cls._set_field("expired_third_wave", "valid_hours", hours_int)
+
+ @classmethod
+ def get_third_wave_trigger_days(cls) -> int:
+ value = cls._get("expired_third_wave").get("trigger_days", 5)
+ try:
+ return max(2, min(60, int(value)))
+ except (TypeError, ValueError):
+ return 5
+
+ @classmethod
+ def set_third_wave_trigger_days(cls, days: int) -> bool:
+ try:
+ days_int = max(2, min(60, int(days)))
+ except (TypeError, ValueError):
+ return False
+ return cls._set_field("expired_third_wave", "trigger_days", days_int)
+
+ @classmethod
+ def are_notifications_globally_enabled(cls) -> bool:
+ return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
diff --git a/app/states.py b/app/states.py
index 45e87e21..782fae7d 100644
--- a/app/states.py
+++ b/app/states.py
@@ -84,9 +84,10 @@ class AdminStates(StatesGroup):
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
-
+
editing_rules_page = State()
-
+ editing_notification_value = State()
+
confirming_sync = State()
editing_server_name = State()
diff --git a/locales/en.json b/locales/en.json
index 1b416564..2f370239 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -129,6 +129,7 @@
"ACCESS_DENIED": "❌ Access denied",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MONITORING": "🔍 Monitoring",
+ "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
"ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n",
"ADMIN_PROMOCODES": "🎫 Promo codes",
"ADMIN_REFERRALS": "🤝 Referral program",
@@ -484,5 +485,23 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
- "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
+ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
+ "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!",
+ "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
+ "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
+ "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
+ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
+ "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
+ "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
+ "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
+ "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
+ "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
+ "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
+ "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
+ "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
+ "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
+ "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
+ "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
+ "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
+ "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):"
}
diff --git a/locales/ru.json b/locales/ru.json
index 736d38e1..5a9218f1 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -5,6 +5,7 @@
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MONITORING": "🔍 Мониторинг",
+ "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
"ADMIN_REPORTS": "📊 Отчеты",
"ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n",
"ADMIN_PROMOCODES": "🎫 Промокоды",
@@ -484,5 +485,23 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
- "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
+ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
+ "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
+ "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
+ "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
+ "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
+ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
+ "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
+ "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
+ "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
+ "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
+ "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
+ "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
+ "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
+ "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
+ "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
+ "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
+ "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
+ "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
+ "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):"
}