fix: notification sent for non-deactivated subs + webhook race condition

Two fixes for channel subscription enforcement:

1. Middleware notification guard: the deactivation notification was sent
   even when deactivated_subs was empty (no subs actually deactivated).
   Also fixed len(active_subs) -> len(deactivated_subs) for multi-tariff
   notification text selection.

2. Webhook echo race condition: when a user quickly leaves and rejoins
   a channel, the delayed user.disabled webhook from RemnaWave could
   re-deactivate a subscription that was already reactivated.
   Fix: stamp last_webhook_update_at on reactivation (both channel_member
   handler and middleware), then guard _handle_user_disabled against
   re-deactivating recently-reactivated ACTIVE subscriptions using the
   existing is_recently_updated_by_webhook (60s window).
This commit is contained in:
Fringg
2026-04-02 06:14:45 +03:00
parent f284351c51
commit b04157c913
3 changed files with 41 additions and 22 deletions
+3
View File
@@ -78,6 +78,9 @@ async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Ставим штамп чтобы webhook user.disabled (echo от нашего disable)
# не переотключил подписку при быстрой реподписке
subscription.last_webhook_update_at = datetime.now(UTC)
logger.info(
'Subscriptions reactivated via channel event',
telegram_id=user.id,
+25 -22
View File
@@ -406,29 +406,30 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
# Notify user about deactivation
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(active_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
if deactivated_subs:
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(deactivated_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error(
@@ -465,6 +466,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Штамп для защиты от echo-webhook user.disabled
subscription.last_webhook_update_at = datetime.now(UTC)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription reactivated after channel subscribe',
+13
View File
@@ -27,6 +27,7 @@ from app.database.crud.subscription import (
decrement_subscription_server_counts,
expire_subscription,
get_subscription_by_user_id,
is_recently_updated_by_webhook,
reactivate_subscription,
update_subscription_usage,
)
@@ -706,6 +707,18 @@ class RemnaWaveWebhookService:
await db.commit()
return
# Защита от echo-webhook: если подписка была недавно реактивирована
# (канал-реподписка ставит last_webhook_update_at), пропускаем
if subscription.status == SubscriptionStatus.ACTIVE.value and is_recently_updated_by_webhook(subscription):
logger.info(
'Webhook user.disabled: подписка недавно реактивирована, пропуск echo-webhook',
subscription_id=subscription.id,
user_id=user.id,
)
self._stamp_webhook_update(subscription)
await db.commit()
return
self._stamp_webhook_update(subscription)
if subscription.status != SubscriptionStatus.DISABLED.value:
await deactivate_subscription(db, subscription)