feat: webhook protection — prevent sync/monitoring from overwriting webhook data
Add last_webhook_update_at timestamp to Subscription model. When a webhook handler modifies a subscription, it stamps this field. Auto-sync, monitoring, and force-check services skip subscriptions updated by webhook within the last 60 seconds, preventing stale panel data from overwriting fresh real-time changes. - Add last_webhook_update_at column + migration - Stamp all 8 webhook handlers with commit in every code path - Add is_recently_updated_by_webhook() guard in 12 sync/monitoring paths - Add REMNAWAVE_WEBHOOK_* variables to .env.example - Add webhook setup documentation to README with Caddy/nginx examples - Fix pre-existing yookassa webhook test (mock AsyncSessionLocal)
This commit is contained in:
@@ -187,6 +187,16 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
|
||||
@@ -610,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -818,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1055,6 +1077,103 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В панели Remnawave перейдите в раздел **Настройки > Вебхуки** и создайте новый вебхук:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
- **Events**: выберите нужные события или все
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
@@ -23,6 +23,16 @@ from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WEBHOOK_GUARD_SECONDS = 60
|
||||
|
||||
|
||||
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
"""Return True if subscription was updated by webhook within guard window."""
|
||||
if not subscription.last_webhook_update_at:
|
||||
return False
|
||||
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
result = await db.execute(
|
||||
|
||||
@@ -1137,6 +1137,8 @@ class Subscription(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(DateTime, nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
|
||||
@@ -3613,6 +3613,33 @@ async def add_subscription_crypto_link_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_last_webhook_update_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'last_webhook_update_at')
|
||||
if column_exists:
|
||||
logger.info('ℹ️ Колонка last_webhook_update_at уже существует')
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at TIMESTAMP'))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
else:
|
||||
logger.error(f'Неподдерживаемый тип БД для добавления last_webhook_update_at: {db_type}')
|
||||
return False
|
||||
|
||||
logger.info('✅ Добавлена колонка last_webhook_update_at в таблицу subscriptions')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка добавления колонки last_webhook_update_at: {e}')
|
||||
return False
|
||||
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -7104,6 +7131,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_WEBHOOK_UPDATE_AT ===')
|
||||
webhook_column_ready = await add_subscription_last_webhook_update_column()
|
||||
if webhook_column_ready:
|
||||
logger.info('✅ Колонка last_webhook_update_at готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонкой last_webhook_update_at')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
|
||||
@@ -262,9 +262,18 @@ class MonitoringService:
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск expire подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
from app.database.crud.subscription import expire_subscription
|
||||
|
||||
await expire_subscription(db, subscription)
|
||||
@@ -288,6 +297,15 @@ class MonitoringService:
|
||||
|
||||
async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None:
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}')
|
||||
@@ -299,6 +317,14 @@ class MonitoringService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-check guard after refresh (webhook could have committed between first check and refresh)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно (после refresh)',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
|
||||
|
||||
@@ -550,6 +576,8 @@ class MonitoringService:
|
||||
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
|
||||
|
||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
@@ -636,6 +664,12 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -670,6 +704,12 @@ class MonitoringService:
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск реактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -1007,6 +1047,15 @@ class MonitoringService:
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
@@ -1874,11 +1923,19 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> dict[str, int]:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск force-check подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
|
||||
@@ -1454,10 +1454,20 @@ class RemnaWaveService:
|
||||
for telegram_id, db_user in users_to_deactivate:
|
||||
cleanup_mutation: _UUIDMapMutation | None = None
|
||||
try:
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
# Skip if recently updated by webhook
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if subscription and is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
if db_user.remnawave_uuid and hwid_api_client:
|
||||
try:
|
||||
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
|
||||
@@ -1664,7 +1674,7 @@ class RemnaWaveService:
|
||||
|
||||
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, is_recently_updated_by_webhook
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Всегда используем async CRUD запрос для получения подписки,
|
||||
@@ -1675,6 +1685,14 @@ class RemnaWaveService:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
return
|
||||
|
||||
# Skip if recently updated by webhook (prevent stale data overwrite)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск синхронизации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
expire_at_str = panel_user.get('expireAt', '')
|
||||
|
||||
@@ -2499,12 +2517,20 @@ class RemnaWaveService:
|
||||
await self._update_subscription_from_panel_data(db, user, panel_user)
|
||||
stats['updated'] += 1
|
||||
elif subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
is_recently_updated_by_webhook,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
else:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
|
||||
except Exception as sub_error:
|
||||
logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}')
|
||||
@@ -2547,6 +2573,8 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
current_time = self._now_utc()
|
||||
# Конвертируем end_date в UTC для корректного сравнения
|
||||
end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
@@ -2555,6 +2583,7 @@ class RemnaWaveService:
|
||||
if (
|
||||
end_date_utc + expiry_buffer <= current_time
|
||||
and subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and not is_recently_updated_by_webhook(subscription)
|
||||
):
|
||||
time_since_expiry = current_time - end_date_utc
|
||||
logger.warning(
|
||||
|
||||
@@ -392,6 +392,15 @@ class RemnaWaveWebhookService:
|
||||
except Exception:
|
||||
logger.exception('Notification delivery failed for user %s, text_key %s', user.id, text_key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhook timestamp helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _stamp_webhook_update(subscription: Subscription) -> None:
|
||||
"""Mark subscription as recently updated by webhook to prevent sync overwrite."""
|
||||
subscription.last_webhook_update_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User event handlers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -399,36 +408,52 @@ class RemnaWaveWebhookService:
|
||||
async def _handle_user_expired(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription and subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id)
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_user_disabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription and subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id)
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DISABLED', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_user_enabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id)
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_ENABLED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
async def _handle_user_limited(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription and subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id)
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_LIMITED', reply_markup=self._get_traffic_keyboard(user))
|
||||
|
||||
@@ -436,6 +461,7 @@ class RemnaWaveWebhookService:
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
await update_subscription_usage(db, subscription, 0.0)
|
||||
# Re-enable if was disabled due to traffic limit
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
@@ -496,24 +522,32 @@ class RemnaWaveWebhookService:
|
||||
subscription.subscription_url = subscription_url
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.flush()
|
||||
logger.info('Webhook: subscription %s modified (synced from panel) for user %s', subscription.id, user.id)
|
||||
await db.commit()
|
||||
|
||||
async def _handle_user_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription and subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info(
|
||||
'Webhook: subscription %s marked expired (user deleted in panel) for user %s', subscription.id, user.id
|
||||
)
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info(
|
||||
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
|
||||
subscription.id,
|
||||
user.id,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
# Clear remnawave linkage
|
||||
if user.remnawave_uuid:
|
||||
user.remnawave_uuid = None
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
@@ -536,12 +570,14 @@ class RemnaWaveWebhookService:
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Webhook: subscription %s credentials revoked/updated for user %s', subscription.id, user.id
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_REVOKED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
|
||||
+12
-3
@@ -111,10 +111,19 @@ async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> we
|
||||
|
||||
|
||||
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get_db():
|
||||
yield DummyDB()
|
||||
"""Mock AsyncSessionLocal used by the webhook handler."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.rollback = AsyncMock()
|
||||
mock_session.execute = AsyncMock()
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.AsyncSessionLocal', lambda: ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user