diff --git a/.env.example b/.env.example
index 4e295c71..d129df68 100644
--- a/.env.example
+++ b/.env.example
@@ -194,6 +194,7 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
+# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
diff --git a/README.md b/README.md
index ebc0678a..a225d92b 100644
--- a/README.md
+++ b/README.md
@@ -362,6 +362,7 @@ PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
+# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py
new file mode 100644
index 00000000..d59b6190
--- /dev/null
+++ b/app/database/crud/notification.py
@@ -0,0 +1,52 @@
+import logging
+from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, delete
+
+from app.database.models import SentNotification
+
+logger = logging.getLogger(__name__)
+
+
+async def notification_sent(
+ db: AsyncSession,
+ user_id: int,
+ subscription_id: int,
+ notification_type: str,
+ days_before: Optional[int] = None,
+) -> bool:
+ result = await db.execute(
+ select(SentNotification).where(
+ SentNotification.user_id == user_id,
+ SentNotification.subscription_id == subscription_id,
+ SentNotification.notification_type == notification_type,
+ SentNotification.days_before == days_before,
+ )
+ )
+ return result.scalar_one_or_none() is not None
+
+
+async def record_notification(
+ db: AsyncSession,
+ user_id: int,
+ subscription_id: int,
+ notification_type: str,
+ days_before: Optional[int] = None,
+) -> None:
+ notification = SentNotification(
+ user_id=user_id,
+ subscription_id=subscription_id,
+ notification_type=notification_type,
+ days_before=days_before,
+ )
+ db.add(notification)
+ await db.commit()
+
+
+async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
+ await db.execute(
+ delete(SentNotification).where(
+ SentNotification.subscription_id == subscription_id
+ )
+ )
+ await db.commit()
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index dfda9e1c..f69aa7ad 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
- Subscription, SubscriptionStatus, User,
+ Subscription, SubscriptionStatus, User,
SubscriptionServer
)
+from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
@@ -121,10 +122,11 @@ async def extend_subscription(
await db.commit()
await db.refresh(subscription)
-
+ await clear_notifications(db, subscription.id)
+
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
-
+
return subscription
diff --git a/app/database/models.py b/app/database/models.py
index 8c392b10..70c9874a 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -555,6 +555,20 @@ class MonitoringLog(Base):
created_at = Column(DateTime, default=func.now())
+
+class SentNotification(Base):
+ __tablename__ = "sent_notifications"
+
+ 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="CASCADE"), nullable=False)
+ notification_type = Column(String(50), nullable=False)
+ days_before = Column(Integer, nullable=True)
+ created_at = Column(DateTime, default=func.now())
+
+ user = relationship("User", backref="sent_notifications")
+ subscription = relationship("Subscription", backref="sent_notifications")
+
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 0f1fa72e..dce7148b 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -416,6 +416,12 @@ async def activate_trial(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
+ elif connect_mode == "link":
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
+ [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
+ [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
+ ])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
@@ -1920,6 +1926,12 @@ async def confirm_purchase(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
+ elif connect_mode == "link":
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
+ [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
+ [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
+ ])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
@@ -2687,7 +2699,30 @@ async def handle_connect_subscription(
reply_markup=keyboard,
parse_mode="HTML"
)
-
+
+ elif connect_mode == "link":
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="🔗 Подключиться",
+ url=subscription.subscription_url
+ )
+ ],
+ [
+ InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ f"""
+🚀 Подключить подписку
+
+🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:
+ """,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+
else:
device_text = f"""
📱 Подключить подписку
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 7ab3e675..7ea02102 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -163,6 +163,10 @@ def get_subscription_keyboard(
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
])
+ elif connect_mode == "link":
+ keyboard.append([
+ InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
+ ])
else:
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 80bcb6b8..39d2f689 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -17,6 +17,10 @@ from app.database.crud.user import (
get_user_by_id, get_inactive_users, delete_user,
subtract_user_balance
)
+from app.database.crud.notification import (
+ notification_sent,
+ record_notification,
+)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
@@ -186,31 +190,30 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
-
- notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
+
user_key = f"user_{user.telegram_id}_today"
-
- if (notification_key in self._notified_users or
+
+ if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
user_key in all_processed_users):
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
continue
-
+
should_send = True
for other_days in warning_days:
- if other_days < days:
+ if other_days < days:
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
if any(s.user_id == user.id for s in other_subs):
should_send = False
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
break
-
+
if not should_send:
continue
-
+
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
- self._notified_users.add(notification_key)
+ await record_notification(db, user.id, subscription.id, "expiring", days)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
@@ -249,15 +252,14 @@ class MonitoringService:
user = subscription.user
if not user:
continue
-
- notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
- if notification_key in self._notified_users:
- continue
-
+
+ if await notification_sent(db, user.id, subscription.id, "trial_2h"):
+ continue
+
if self.bot:
success = await self._send_trial_ending_notification(user, subscription)
if success:
- self._notified_users.add(notification_key)
+ await record_notification(db, user.id, subscription.id, "trial_2h")
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
diff --git a/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py
new file mode 100644
index 00000000..e7a0bff9
--- /dev/null
+++ b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py
@@ -0,0 +1,27 @@
+"""add sent notifications table"""
+
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = '8fd1e338eb45'
+down_revision: Union[str, None] = '3d9b35c6bd8f'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'sent_notifications',
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
+ sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
+ sa.Column('notification_type', sa.String(length=50), nullable=False),
+ sa.Column('days_before', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
+ sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('sent_notifications')
diff --git a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py
new file mode 100644
index 00000000..08ae5543
--- /dev/null
+++ b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py
@@ -0,0 +1,24 @@
+"""add cascade delete to sent notifications"""
+
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = 'cbd1be472f3d'
+down_revision: Union[str, None] = '8fd1e338eb45'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
+ op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
+ op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
+ op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
+
+
+def downgrade() -> None:
+ op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
+ op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
+ op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
+ op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])