mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-01 15:52:30 +00:00
Merge pull request #284 from Fr1ngg/revert-283-wmf5on-bedolaga/add-automated-user-notifications
Revert "Implement subscription follow-up notifications and admin configuration"
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
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
|
||||
@@ -14,7 +14,6 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -359,7 +358,6 @@ 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)
|
||||
@@ -422,9 +420,8 @@ 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:
|
||||
@@ -768,28 +765,6 @@ 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"
|
||||
|
||||
|
||||
@@ -520,94 +520,6 @@ 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:
|
||||
@@ -1555,13 +1467,6 @@ 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:
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -13,77 +12,11 @@ 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 = (
|
||||
"🔔 <b>Уведомления пользователям</b>\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):
|
||||
@@ -119,180 +52,6 @@ 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 = (
|
||||
"⚙️ <b>Настройки мониторинга</b>\n\n"
|
||||
f"🔔 <b>Уведомления пользователям:</b> {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_monitoring")],
|
||||
])
|
||||
|
||||
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):
|
||||
@@ -607,53 +366,5 @@ 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)
|
||||
@@ -17,13 +17,12 @@ from app.database.crud.subscription import (
|
||||
add_subscription_squad, update_subscription_autopay,
|
||||
add_subscription_servers
|
||||
)
|
||||
from app.database.crud.user import subtract_user_balance, add_user_balance
|
||||
from app.database.crud.user import subtract_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,
|
||||
@@ -4069,76 +4068,6 @@ 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,
|
||||
@@ -5034,11 +4963,6 @@ 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"
|
||||
|
||||
@@ -93,17 +93,14 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa
|
||||
|
||||
def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
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=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
@@ -785,9 +782,6 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки уведомлений", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
|
||||
]
|
||||
|
||||
@@ -21,15 +21,10 @@ 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
|
||||
@@ -85,16 +80,10 @@ 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_inactivity_notifications(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
@@ -261,7 +250,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))
|
||||
@@ -299,202 +288,7 @@ 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)
|
||||
@@ -671,7 +465,7 @@ class MonitoringService:
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
@@ -707,149 +501,7 @@ 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",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
else:
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\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",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\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",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
else:
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\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)
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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))
|
||||
@@ -84,10 +84,9 @@ 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()
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_MONITORING_SETTINGS": "🔔 Notification settings",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
@@ -485,23 +484,5 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\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": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\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):"
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_MONITORING_SETTINGS": "🔔 Настройки уведомлений",
|
||||
"ADMIN_REPORTS": "📊 Отчеты",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
@@ -485,23 +484,5 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\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):"
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user