Compare commits

...

19 Commits

Author SHA1 Message Date
Egor 0d5638f778 Merge pull request #2838 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.44.0
2026-04-02 07:24:58 +03:00
github-actions[bot] 7836720db3 chore(main): release 3.44.0 2026-04-02 04:24:32 +00:00
Egor dcb90d6139 Merge pull request #2837 from BEDOLAGA-DEV/dev
Dev
2026-04-02 07:24:07 +03:00
Fringg 96c420e917 style: fix ruff format for severpay.py 2026-04-02 07:17:36 +03:00
Fringg 9d63635502 feat: add SberPay as KassaAI sub-method (payment_system_id=43)
Adds SberPay alongside existing SBP (44) and Card (36) sub-methods.

Changes across all 8 required files:
- config.py: KASSA_AI_SBERPAY_ENABLED, display name, helper methods
- kassa_ai_service.py: payment_system_id=43 in KASSA_AI_SUB_METHODS
- kassa_ai.py handler: method config + start_kassa_ai_sberpay_topup
- main.py: route tuple, import, handler registration
- payment_service.py: guest purchase flow method check
- cabinet/balance.py: KASSA_AI_OPTION_MAP sberpay=43
- payment_method_config_service.py: sub_option for admin panel
- inline.py: keyboard button + fallback condition guard

Env vars: KASSA_AI_SBERPAY_ENABLED, KASSA_AI_SBERPAY_DISPLAY_NAME
2026-04-02 06:47:57 +03:00
Egor c4c5d330be Merge pull request #2836 from BEDOLAGA-DEV/main
w
2026-04-02 06:39:28 +03:00
Fringg 977950b97f fix: address review issues in PR #2829 webhook intentional deletion guard
5 issues found by review agents and fixed:

1. Intentional deletion guard was at top of process_event, skipping ALL
   cleanup (subscription URLs, server counts, expired marking). Moved
   check into _handle_user_deleted so cleanup still runs but re-creation
   is suppressed via subscription_still_valid=False.

2. Variable shadowing: telegram_id loop var in any() generator shadowed
   the outer telegram_id local. Renamed to tid/uid.

3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
   with early return in mark_intentional_panel_deletion.

4. mark_intentional_panel_deletion called inside for-loop with single
   UUID — race window if webhook from first delete arrives before
   second UUID is marked. Moved to before the loop with all UUIDs.

5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
   consistency. Replaced process_event(db=None) test with direct
   mark+detect unit tests + hard cap test.
2026-04-02 06:34:35 +03:00
Fringg 6f6b9fa039 Merge branch 'yazhog/main' into dev 2026-04-02 06:30:45 +03:00
Fringg 6713921887 fix: Pal24 card/sbp option not passed to API in cabinet balance topup
The payment_option (card/sbp) was parsed from the request but never
passed to create_pal24_payment as payment_method. Without it,
_normalize_payment_method(None) defaults to 'sbp', so both Card and
SBP buttons always created SBP payments.
2026-04-02 06:20:21 +03:00
Fringg 2d42152f54 fix: NameError in SeverPay guest payment flow
user variable was unbound when user_id=None (guest purchase path).
Added user=None in the else branch to prevent NameError when
constructing the fallback email.
2026-04-02 06:18:37 +03:00
Fringg 08ca947b2b fix: send telegram_id@telegram.org as email to SeverPay
SeverPay requires a non-empty client_email field. When user has no
email, fallback to {telegram_id}@telegram.org instead of empty string.
2026-04-02 06:16:17 +03:00
Fringg b04157c913 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).
2026-04-02 06:14:45 +03:00
Fringg f284351c51 fix: middleware disables panel VPN for all subs ignoring per-channel settings
In _deactivate_subscription_on_unsubscribe, the loop calling
disable_remnawave_user iterated over all active_subs instead of only
the subscriptions that passed the should_disable_subscription check.

This caused paid subscriptions to be disabled at the RemnaWave panel
level even when disable_paid_on_leave=False, while the DB record
stayed ACTIVE. On rejoin, reactivation found no DISABLED subs in DB
so enable_remnawave_user was never called — VPN stayed off permanently.

Fixed by collecting actually-deactivated subs into a separate list
and using that for both panel disable calls and notifications.
2026-04-02 06:09:39 +03:00
Fringg b607993854 fix: prevent nested state saves and None state loss in promo handler
Two bugs found during review of the previous FSM state restore fix:

1. Re-entering promo flow created nested _prev_data (unbounded growth).
   Now skips save if already in PromoCodeStates.waiting_for_code and
   strips _prev_ keys from saved data to prevent nesting.

2. _restore_previous_state used `if prev_state:` which treated saved
   None state (user at menu) same as "no saved state". Now uses a
   sentinel to distinguish the two cases, correctly restoring None
   state with its data instead of calling state.clear().
2026-04-02 05:59:28 +03:00
Fringg 246659032d fix: promo code activation destroys balance input FSM state
When a user was in BalanceStates.waiting_for_amount and activated a
promo code, process_promocode called state.clear() on all exit paths,
wiping the balance state. Typing the amount then hit the fallback
"Не понимаю эту команду" handler.

Now show_promocode_menu saves the previous FSM state/data before
entering promo flow, and _restore_previous_state restores it after
promo code processing completes.
2026-04-02 05:55:29 +03:00
Fringg 3dc72b00e7 fix: send telegram_id@telegram.org as email to Kassa AI
Kassa AI requires email in format {telegram_id}@telegram.org but we
were sending user_{order_id}@telegram.org as fallback when email was
not provided. Now the fallback uses user.telegram_id directly.
2026-04-02 05:49:01 +03:00
Fringg 033d0da5e0 fix: remove non-existent Platega method code 10, rename 11 to Карты (RUB)
Platega API defines: 2=СБП, 11=Карточный эквайринг, 12=Международная,
13=Крипто. Code 10 does not exist in their API but was defined in our
config as "Банковские карты (RUB)", while real code 11 was mislabeled
as "Банковские карты". This caused two card options to appear in the
admin panel, one of which didn't work.

- Removed code 10 from definitions, defaults, allowed set, .env.example
- Renamed code 11: "Банковские карты" → "Карты (RUB)"
- Removed redundant filter in handlers/balance/platega.py
- Updated tests to match
2026-04-02 05:44:24 +03:00
Fringg 991f0b43e1 fix: autopay failure notifications ignoring 6h cooldown
Two bugs caused users to receive autopay error notifications every
monitoring cycle (hourly) instead of respecting the 6-hour cooldown:

1. subtract_user_balance failure path had no cooldown check at all
2. cache.exists() silently returns False when Redis is disconnected,
   bypassing the try/except cooldown guard

Extracted shared _check_autopay_fail_cooldown / _set_autopay_fail_cooldown
methods with in-memory fallback dict that works even without Redis.
Added cleanup of expired in-memory entries in _cleanup_notification_cache.
2026-04-02 05:44:13 +03:00
yazhog b1820c651d Fix RemnaWave webhook deletion race 2026-03-30 15:56:45 +03:00
25 changed files with 522 additions and 106 deletions
+1 -1
View File
@@ -614,7 +614,7 @@ PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_ACTIVE_METHODS=2,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.43.1"
".": "3.44.0"
}
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
### New Features
* add SberPay as KassaAI sub-method (payment_system_id=43) ([9d63635](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d636355026ad1e50d045e78ffa21e76cfef0774))
### Bug Fixes
* address review issues in PR [#2829](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2829) webhook intentional deletion guard ([977950b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/977950b97f07eecf089152d3f4e678fda373e1e6))
* autopay failure notifications ignoring 6h cooldown ([991f0b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/991f0b43e1e73446690a4fbec7c5c5642ac8c406))
* middleware disables panel VPN for all subs ignoring per-channel settings ([f284351](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f284351c51a6843db0771a92338ec770d5f0d8d2))
* NameError in SeverPay guest payment flow ([2d42152](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d42152f5491b14cc45388e0ffccf8a61848a2f6))
* notification sent for non-deactivated subs + webhook race condition ([b04157c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b04157c91327d9031e9f603a6ad33c708e27d753))
* Pal24 card/sbp option not passed to API in cabinet balance topup ([6713921](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67139218878dca3e75974eb5b5a2ce91d5b1438e))
* prevent nested state saves and None state loss in promo handler ([b607993](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b607993854d1374e7d7c2afbb7fe5cc8824732f5))
* promo code activation destroys balance input FSM state ([2466590](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/246659032de812f1d4502029ab139f3104237d5c))
* remove non-existent Platega method code 10, rename 11 to Карты (RUB) ([033d0da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/033d0da5e0033a2310431586a291b529e3ccb89a))
* send telegram_id@telegram.org as email to Kassa AI ([3dc72b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc72b00e751a69966d2d5830492c82e055b72e6))
* send telegram_id@telegram.org as email to SeverPay ([08ca947](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08ca947b2b2bb29782c86e7b5d6bea71e2811751))
## [3.43.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.0...v3.43.1) (2026-03-31)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.43.1" # x-release-please-version
ARG VERSION="v3.44.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+2 -1
View File
@@ -578,6 +578,7 @@ async def create_topup(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=option,
)
if result:
@@ -698,7 +699,7 @@ async def create_topup(
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
+15 -4
View File
@@ -467,7 +467,7 @@ class Settings(BaseSettings):
PLATEGA_RETURN_URL: str | None = None
PLATEGA_FAILED_URL: str | None = None
PLATEGA_CURRENCY: str = 'RUB'
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
PLATEGA_ACTIVE_METHODS: str = '2,11,12,13'
PLATEGA_INLINE_METHODS: bool = True
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
@@ -558,6 +558,8 @@ class Settings(BaseSettings):
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -1839,7 +1841,7 @@ class Settings(BaseSettings):
except ValueError:
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
if method_code in {2, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
@@ -1852,8 +1854,7 @@ class Settings(BaseSettings):
def get_platega_method_definitions() -> dict[int, dict[str, str]]:
return {
2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'},
10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'},
11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'},
11: {'name': 'Карты (RUB)', 'title': '💳 Карты (RUB)'},
12: {'name': 'Международные карты', 'title': '🌍 Международные карты'},
13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'},
}
@@ -1981,6 +1982,16 @@ class Settings(BaseSettings):
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_kassa_ai_sberpay_enabled(self) -> bool:
return self.KASSA_AI_SBERPAY_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sberpay_display_name(self) -> str:
name = (self.KASSA_AI_SBERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SberPay (KassaAI)'
def get_kassa_ai_sberpay_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sberpay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+16
View File
@@ -39,6 +39,11 @@ _KASSA_AI_METHOD_CONFIG = {
'display_name': settings.get_kassa_ai_card_display_name,
'unavailable_text': 'KassaAI Карта временно недоступна',
},
'kassa_ai_sberpay': {
'is_enabled': settings.is_kassa_ai_sberpay_enabled,
'display_name': settings.get_kassa_ai_sberpay_display_name,
'unavailable_text': 'KassaAI SberPay временно недоступен',
},
}
@@ -350,3 +355,14 @@ async def start_kassa_ai_card_topup(
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
@error_handler
async def start_kassa_ai_sberpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI SberPay top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sberpay')
+3 -1
View File
@@ -133,7 +133,7 @@ async def route_payment_by_method(
)
return True
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card', 'kassa_ai_sberpay'):
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
@@ -701,6 +701,7 @@ def register_balance_handlers(dp: Dispatcher):
from .kassa_ai import (
start_kassa_ai_card_topup,
start_kassa_ai_sberpay_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
@@ -708,6 +709,7 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(start_kassa_ai_sberpay_topup, F.data == 'topup_kassa_ai_sberpay')
from .riopay import start_riopay_topup
+1 -2
View File
@@ -20,8 +20,7 @@ logger = structlog.get_logger(__name__)
def _get_active_methods() -> list[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
return settings.get_platega_active_methods()
async def _prompt_amount(
+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,
+36 -5
View File
@@ -34,7 +34,21 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
else:
raise
# Сохраняем предыдущее состояние, чтобы восстановить после промокода
previous_state = await state.get_state()
previous_data = await state.get_data()
# Не перезаписываем сохранённое состояние при повторном входе в промо-флоу
if previous_state == PromoCodeStates.waiting_for_code.state:
await callback.answer()
return
# Убираем мета-ключи чтобы не создавать вложенность
previous_data.pop('_prev_state', None)
previous_data.pop('_prev_data', None)
await state.set_state(PromoCodeStates.waiting_for_code)
await state.update_data(_prev_state=previous_state, _prev_data=previous_data)
await callback.answer()
@@ -75,6 +89,23 @@ async def activate_promocode_for_registration(
return result
_NO_SAVED_STATE = object()
async def _restore_previous_state(state: FSMContext) -> None:
"""Восстанавливает FSM-состояние, которое было до входа в промокод-флоу."""
data = await state.get_data()
prev_state = data.get('_prev_state', _NO_SAVED_STATE)
prev_data = data.get('_prev_data') or {}
if prev_state is _NO_SAVED_STATE:
# Не было сохранённого состояния — defensive clear
await state.clear()
else:
# Восстанавливаем предыдущее состояние (включая None = меню без FSM)
await state.set_state(prev_state)
await state.set_data(prev_data)
@error_handler
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
texts = get_texts(db_user.language)
@@ -108,7 +139,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
).format(cooldown=cooldown),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
# Лимит на стакинг (макс активаций в день)
@@ -120,7 +151,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
@@ -131,7 +162,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
texts.PROMOCODE_SUCCESS.format(description=result['description']),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
elif result.get('error') == 'select_subscription':
# Multi-tariff: user needs to choose which subscription to apply days to
eligible = result.get('eligible_subscriptions', [])
@@ -156,7 +187,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await state.clear()
await _restore_previous_state(state)
else:
# Записываем неудачную попытку только для not_found (перебор)
if result['error'] == 'not_found':
@@ -188,7 +219,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
error_text = error_messages.get(result['error'], texts.PROMOCODE_INVALID)
await message.answer(error_text, reply_markup=get_back_keyboard(db_user.language))
await state.clear()
await _restore_previous_state(state)
async def handle_promo_subscription_select(
+13
View File
@@ -1755,10 +1755,23 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_kassa_ai_sberpay_enabled():
sberpay_name = settings.get_kassa_ai_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('kassa_ai_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_kassa_ai_enabled()
and not settings.is_kassa_ai_sbp_enabled()
and not settings.is_kassa_ai_card_enabled()
and not settings.is_kassa_ai_sberpay_enabled()
):
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append(
+28 -23
View File
@@ -370,6 +370,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Per-channel settings: check if any unsubscribed channel requires deactivation
unsubscribed = [ch for ch in channels if not ch.get('is_subscribed', False)]
deactivated_subs = []
for subscription in active_subs:
should_disable = any(
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
@@ -379,6 +380,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
continue
await deactivate_subscription(db, subscription)
deactivated_subs.append(subscription)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription deactivated after channel unsubscribe',
@@ -387,7 +389,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
service = SubscriptionService()
for subscription in active_subs:
for subscription in deactivated_subs:
panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
@@ -404,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(
@@ -463,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',
+1
View File
@@ -18,6 +18,7 @@ logger = structlog.get_logger(__name__)
KASSA_AI_SUB_METHODS = {
'kassa_ai_sbp': {'payment_system_id': 44},
'kassa_ai_card': {'payment_system_id': 36},
'kassa_ai_sberpay': {'payment_system_id': 43},
}
# Кэш для публичного IP
+84 -45
View File
@@ -90,6 +90,8 @@ class MonitoringService:
self._notified_users: set[str] = set()
self._last_cleanup = datetime.now(UTC)
self._sla_task = None
# In-memory fallback для cooldown автоплатежей (на случай недоступности Redis)
self._autopay_fail_notified_at: dict[int, datetime] = {}
async def _send_message_with_logo(
self,
@@ -276,8 +278,76 @@ class MonitoringService:
if (current_time - self._last_cleanup).total_seconds() >= 3600:
old_count = len(self._notified_users)
self._notified_users.clear()
# Чистим просроченные записи cooldown автоплатежей
cutoff = current_time - timedelta(seconds=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS)
expired_ids = [uid for uid, ts in self._autopay_fail_notified_at.items() if ts < cutoff]
for uid in expired_ids:
del self._autopay_fail_notified_at[uid]
self._last_cleanup = current_time
logger.info('🧹 Очищен кеш уведомлений ( записей)', old_count=old_count)
logger.info(
'🧹 Очищен кеш уведомлений',
old_count=old_count,
autopay_cooldown_evicted=len(expired_ids),
autopay_cooldown_remaining=len(self._autopay_fail_notified_at),
)
async def _check_autopay_fail_cooldown(self, user_id: int, user_identifier: str) -> bool:
"""Проверяет, можно ли отправить уведомление об ошибке автоплатежа.
Использует Redis как primary хранилище cooldown, с in-memory fallback.
Returns True если уведомление можно отправить.
"""
# 1. In-memory fallback (работает даже без Redis)
last_notified = self._autopay_fail_notified_at.get(user_id)
if last_notified:
elapsed = (datetime.now(UTC) - last_notified).total_seconds()
if elapsed < AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS:
logger.debug(
'Пропуск уведомления об ошибке автоплатежа — in-memory cooldown активен',
user_identifier=user_identifier,
elapsed_seconds=int(elapsed),
)
return False
# 2. Redis check (если доступен)
cooldown_key = f'autopay_insufficient_balance_notified:{user_id}'
try:
if await cache.exists(cooldown_key):
logger.debug(
'Пропуск уведомления об ошибке автоплатежа — Redis cooldown активен',
user_identifier=user_identifier,
)
return False
except Exception as redis_err:
logger.warning(
'Ошибка проверки cooldown в Redis, используем in-memory fallback',
user_identifier=user_identifier,
redis_err=redis_err,
)
return True
async def _set_autopay_fail_cooldown(self, user_id: int, user_identifier: str) -> None:
"""Устанавливает cooldown после отправки уведомления об ошибке автоплатежа."""
# In-memory (всегда)
self._autopay_fail_notified_at[user_id] = datetime.now(UTC)
# Redis (если доступен)
cooldown_key = f'autopay_insufficient_balance_notified:{user_id}'
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'Не удалось установить cooldown в Redis, in-memory fallback активен',
user_identifier=user_identifier,
redis_err=redis_err,
)
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
@@ -1264,42 +1334,24 @@ class MonitoringService:
)
else:
failed_count += 1
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
)
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier
)
else:
failed_count += 1
# Проверяем кулдаун уведомления через Redis, чтобы не спамить
# при каждом срабатывании мониторинга
cooldown_key = f'autopay_insufficient_balance_notified:{user.id}'
should_notify = True
try:
if await cache.exists(cooldown_key):
should_notify = False
logger.debug(
'💳 Пропуск уведомления о недостаточном балансе для пользователя — кулдаун активен',
user_identifier=user_identifier,
)
except Exception as redis_err:
# Fallback: если Redis недоступен — отправляем уведомление
logger.warning(
'⚠️ Ошибка проверки кулдауна в Redis для пользователя : . Отправляем уведомление.',
user_identifier=user_identifier,
redis_err=redis_err,
)
if should_notify:
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
@@ -1309,20 +1361,7 @@ class MonitoringService:
user=user,
reason='Недостаточно средств на балансе',
)
# Ставим ключ кулдауна после отправки
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'⚠️ Не удалось установить кулдаун в Redis для пользователя',
user_identifier=user_identifier,
redis_err=redis_err,
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier
+4 -1
View File
@@ -92,11 +92,14 @@ class KassaAiPaymentMixin:
try:
# Используем API для создания заказа
# KassaAI требует email в формате {telegram_id}@telegram.org
target_email = email or (f'{user.telegram_id}@telegram.org' if user and user.telegram_id else None)
result = await kassa_ai_service.create_order(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
email=target_email,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
+7 -1
View File
@@ -73,6 +73,7 @@ class SeverPayPaymentMixin:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
@@ -94,12 +95,17 @@ class SeverPayPaymentMixin:
}
try:
# SeverPay требует обязательный client_email
target_email = email or (
f'{user.telegram_id}@telegram.org' if user and user.telegram_id else f'{tg_id}@telegram.org'
)
# Используем API для создания платежа
result = await severpay_service.create_payment(
order_id=order_id,
amount=amount_rubles,
currency=currency,
client_email=email or '',
client_email=target_email,
client_id=str(tg_id),
url_return=return_url or settings.SEVERPAY_RETURN_URL,
lifetime=lifetime,
@@ -129,6 +129,7 @@ def _get_method_defaults() -> dict:
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
{'id': 'card', 'name': 'Карта'},
{'id': 'sberpay', 'name': 'SberPay'},
],
},
'riopay': {
+1 -1
View File
@@ -703,7 +703,7 @@ class PaymentService(
return None
# --- KassaAI ----------------------------------------------------------
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card', 'kassa_ai_sberpay'):
if not settings.is_kassa_ai_enabled():
logger.warning('KassaAI is not enabled, cannot create guest payment')
return None
+113 -4
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,
)
@@ -124,11 +125,14 @@ _ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connect
class RemnaWaveWebhookService:
"""Processes incoming webhooks from RemnaWave backend."""
# In-memory guard: tracks recent panel recreations per subscription_id.
# Prevents unbounded user.deleted → recreate → user.deleted loops.
# Key: subscription_id, Value: datetime of last recreation attempt.
# NOTE: In-memory guards. Only correct with a single-worker deployment.
# For multi-worker setups, move to Redis or another shared store.
_recent_recreations: dict[int, datetime] = {}
_RECREATION_GUARD_SECONDS: int = 120 # 2-minute cooldown
_intentional_panel_deletions_by_uuid: dict[str, datetime] = {}
_intentional_panel_deletions_by_telegram_id: dict[int, datetime] = {}
_INTENTIONAL_PANEL_DELETION_GUARD_SECONDS: int = 300
_MAX_INTENTIONAL_ENTRIES: int = 10_000
def __init__(self, bot: Bot) -> None:
self.bot = bot
@@ -169,6 +173,87 @@ class RemnaWaveWebhookService:
"""Check if the event is admin-scoped (no DB session needed)."""
return event_name in self._admin_handlers
@classmethod
def _prune_intentional_panel_deletions(cls) -> None:
if not cls._intentional_panel_deletions_by_uuid and not cls._intentional_panel_deletions_by_telegram_id:
return
now = datetime.now(UTC)
uuid_keys = [
key
for key, created_at in cls._intentional_panel_deletions_by_uuid.items()
if (now - created_at).total_seconds() >= cls._INTENTIONAL_PANEL_DELETION_GUARD_SECONDS
]
for key in uuid_keys:
del cls._intentional_panel_deletions_by_uuid[key]
telegram_keys = [
key
for key, created_at in cls._intentional_panel_deletions_by_telegram_id.items()
if (now - created_at).total_seconds() >= cls._INTENTIONAL_PANEL_DELETION_GUARD_SECONDS
]
for key in telegram_keys:
del cls._intentional_panel_deletions_by_telegram_id[key]
@classmethod
def mark_intentional_panel_deletion(
cls,
*,
panel_uuids: list[str] | None = None,
telegram_id: int | None = None,
) -> None:
cls._prune_intentional_panel_deletions()
total = len(cls._intentional_panel_deletions_by_uuid) + len(cls._intentional_panel_deletions_by_telegram_id)
if total >= cls._MAX_INTENTIONAL_ENTRIES:
logger.warning('Intentional deletion guard at capacity, skipping', total=total)
return
now = datetime.now(UTC)
for panel_uuid in panel_uuids or []:
normalized = (panel_uuid or '').strip()
if normalized:
cls._intentional_panel_deletions_by_uuid[normalized] = now
if telegram_id is not None:
cls._intentional_panel_deletions_by_telegram_id[int(telegram_id)] = now
@classmethod
def _is_intentional_panel_deletion_event(cls, data: dict[str, Any]) -> bool:
cls._prune_intentional_panel_deletions()
candidate_uuids: list[str] = []
candidate_telegram_ids: list[int] = []
for value in (data.get('uuid'), data.get('userUuid')):
if value:
candidate_uuids.append(str(value).strip())
telegram_id = data.get('telegramId')
if telegram_id:
try:
candidate_telegram_ids.append(int(telegram_id))
except (TypeError, ValueError):
pass
nested_user = data.get('user')
if isinstance(nested_user, dict):
nested_uuid = nested_user.get('uuid')
if nested_uuid:
candidate_uuids.append(str(nested_uuid).strip())
nested_tid = nested_user.get('telegramId')
if nested_tid:
try:
candidate_telegram_ids.append(int(nested_tid))
except (TypeError, ValueError):
pass
return any(uid in cls._intentional_panel_deletions_by_uuid for uid in candidate_uuids) or any(
tid in cls._intentional_panel_deletions_by_telegram_id for tid in candidate_telegram_ids
)
async def process_event(self, db: AsyncSession | None, event_name: str, data: dict) -> bool:
"""Route event to the appropriate handler.
@@ -706,6 +791,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)
@@ -948,10 +1045,22 @@ class RemnaWaveWebhookService:
logger.error('Webhook: user not found after rollback', user_id=user_id)
return
# Intentional admin deletion: cleanup runs (fields cleared above), but skip re-creation
is_intentional = self._is_intentional_panel_deletion_event(data)
if is_intentional:
logger.info(
'Webhook user.deleted: intentional admin deletion, cleanup done, skipping re-creation',
sub_id=sub_id,
user_id=user_id,
)
# Check if subscription has a future end_date — likely a spurious user.deleted
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
subscription_still_valid = (
subscription is not None and subscription.end_date is not None and subscription.end_date > datetime.now(UTC)
not is_intentional
and subscription is not None
and subscription.end_date is not None
and subscription.end_date > datetime.now(UTC)
)
if subscription:
+10
View File
@@ -817,6 +817,16 @@ class UserService:
else:
delete_mode = 'delete' if force_panel_delete else settings.get_remnawave_user_delete_mode()
# Помечаем ВСЕ UUID до цикла, чтобы webhook от первого удаления
# не пришёл раньше чем помечены остальные
if delete_mode == 'delete':
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
RemnaWaveWebhookService.mark_intentional_panel_deletion(
panel_uuids=panel_uuids,
telegram_id=int(user.telegram_id) if user.telegram_id else None,
)
for panel_uuid in panel_uuids:
try:
from app.services.remnawave_service import RemnaWaveService
+11 -6
View File
@@ -97,18 +97,23 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
status_code=status.HTTP_400_BAD_REQUEST,
)
# Extract and validate event info
scope = payload.get('scope', '')
event = payload.get('event', '')
# Extract and validate event info. Recent RemnaWave payloads send only
# the fully-qualified event name (for example "user.modified") without
# a separate top-level scope field.
event = str(payload.get('event', '') or '').strip()
scope = str(payload.get('scope', '') or '').strip()
data = payload.get('data')
if not scope or not event:
logger.warning('RemnaWave webhook: missing scope or event')
if not event:
logger.warning('RemnaWave webhook: missing event')
return JSONResponse(
{'status': 'error', 'reason': 'missing_scope_or_event'},
{'status': 'error', 'reason': 'missing_event'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not scope and '.' in event:
scope = event.split('.', 1)[0]
if not isinstance(data, dict):
data = {}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.43.1"
version = "3.44.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
@@ -114,7 +114,7 @@ async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -
amount_kopeks=50_000,
description='Пополнение счёта',
language='ru',
payment_method_code=10,
payment_method_code=11,
)
assert result is not None
@@ -125,9 +125,9 @@ async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -
assert 'correlation_id' in result and len(result['correlation_id']) == 32
assert captured_args['user_id'] == 42
assert captured_args['amount_kopeks'] == 50_000
assert captured_args['payment_method_code'] == 10
assert captured_args['metadata']['selected_method'] == 10
assert stub.calls and stub.calls[0]['payment_method'] == 10
assert captured_args['payment_method_code'] == 11
assert captured_args['metadata']['selected_method'] == 11
assert stub.calls and stub.calls[0]['payment_method'] == 11
assert stub.calls[0]['amount'] == pytest.approx(500.0)
assert stub.calls[0]['currency'] == 'RUB'
assert captured_args['metadata']['language'] == 'ru'
@@ -209,13 +209,13 @@ def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.Monke
monkeypatch.setattr(
settings,
'PLATEGA_ACTIVE_METHODS',
' 2,10, 11 ;12,13,13,invalid ',
' 2, 11 ;12,13,13,invalid ',
raising=False,
)
methods = settings.get_platega_active_methods()
assert methods == [2, 10, 11, 12, 13]
assert methods == [2, 11, 12, 13]
def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -227,7 +227,7 @@ def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPa
def test_platega_method_display_helpers() -> None:
assert settings.get_platega_method_display_name(10) == 'Банковские карты (RUB)'
assert settings.get_platega_method_display_title(10) == '💳 Карты (RUB)'
assert settings.get_platega_method_display_name(11) == 'Карты (RUB)'
assert settings.get_platega_method_display_title(11) == '💳 Карты (RUB)'
assert settings.get_platega_method_display_name(999) == 'Метод 999'
assert settings.get_platega_method_display_title(999) == 'Platega 999'
+139
View File
@@ -0,0 +1,139 @@
import hashlib
import hmac
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from app.config import settings
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
@pytest.fixture(autouse=True)
def reset_remnawave_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_PATH', '/remnawave-webhook', raising=False)
monkeypatch.setattr(
settings,
'REMNAWAVE_WEBHOOK_SECRET',
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
raising=False,
)
RemnaWaveWebhookService._intentional_panel_deletions_by_uuid.clear()
RemnaWaveWebhookService._intentional_panel_deletions_by_telegram_id.clear()
def _get_route(router, path: str, method: str = 'POST'):
for route in router.routes:
if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()):
return route
raise AssertionError(f'Route {path} with method {method} not found')
def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request:
scope = {
'type': 'http',
'asgi': {'version': '3.0'},
'method': 'POST',
'path': path,
'headers': [(k.lower().encode('latin-1'), v.encode('latin-1')) for k, v in (headers or {}).items()],
}
async def receive() -> dict[str, Any]:
return {'type': 'http.request', 'body': body, 'more_body': False}
return Request(scope, receive)
def _signature(body: bytes) -> str:
secret = settings.REMNAWAVE_WEBHOOK_SECRET or ''
return hmac.new(secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_accepts_event_without_scope(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
process_event = AsyncMock(return_value=True)
service = SimpleNamespace(
process_event=process_event,
is_admin_event=lambda _event_name: True,
)
monkeypatch.setattr(
'app.webserver.remnawave_webhook.RemnaWaveWebhookService',
lambda _bot: service,
)
payload = {
'event': 'user.modified',
'data': {'uuid': 'user-123'},
'timestamp': '2026-03-30T12:00:00.000Z',
}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 200
process_event.assert_awaited_once_with(None, 'user.modified', {'uuid': 'user-123'})
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_rejects_payload_without_event() -> None:
bot = AsyncMock()
payload = {'data': {'uuid': 'user-123'}}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 400
assert json.loads(response.body.decode('utf-8')) == {'status': 'error', 'reason': 'missing_event'}
def test_intentional_panel_deletion_guard_marks_and_detects() -> None:
"""Verify that mark + is_intentional round-trip works correctly."""
RemnaWaveWebhookService.mark_intentional_panel_deletion(
panel_uuids=['panel-user-123'],
telegram_id=8368498066,
)
assert RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'panel-user-123', 'telegramId': 8368498066}
)
# Unknown UUID should not match
assert not RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'unknown-uuid', 'telegramId': 99999}
)
def test_intentional_panel_deletion_guard_respects_hard_cap(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify that the guard stops accepting entries after hitting the cap."""
monkeypatch.setattr(RemnaWaveWebhookService, '_MAX_INTENTIONAL_ENTRIES', 3)
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['a', 'b', 'c'])
# 3 entries — at capacity
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['d'])
# 'd' should NOT be stored (cap reached)
assert 'd' not in RemnaWaveWebhookService._intentional_panel_deletions_by_uuid