diff --git a/backend/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py index c2d1c26..f8b6b97 100644 --- a/backend/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -46,6 +46,13 @@ EVENT_MAP = { days_left=1, ), } +ACTIONABLE_EVENTS = frozenset( + { + *EVENT_MAP.keys(), + "user.expired", + "user.expired_24_hours_ago", + } +) class PanelWebhookService: @@ -128,6 +135,15 @@ class PanelWebhookService: if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED: return + if event_name not in ACTIONABLE_EVENTS: + logging.info( + "Panel webhook event %s ignored: event is not used for subscription " + "notifications; %s", + event_name, + self._payload_log_context(user_payload), + ) + return + async with self.async_session_factory() as session: db_user = await self._user_for_payload(session, user_payload) sub = await self._subscription_for_payload(session, user_payload, db_user) @@ -144,7 +160,17 @@ class PanelWebhookService: ) if not sub: if not telegram_id: - logging.warning("Panel webhook event %s has no local subscription", event_name) + local_user_id = getattr(db_user, "user_id", None) if db_user else None + logging.warning( + "Panel webhook event %s cannot be matched to a local subscription; " + "notification skipped. %s local_user_id=%s. Possible causes: " + "panel user was created outside the bot, subscription was deleted " + "or not synced, panel identifiers changed, or skip_notifications " + "is enabled for the local subscription.", + event_name, + self._payload_log_context(user_payload), + local_user_id or "N/A", + ) return await self._send_legacy_without_dedupe( event_name, @@ -403,6 +429,31 @@ class PanelWebhookService: def _payload_expire_date(user_payload: dict) -> str: return str(user_payload.get("expireAt") or "")[:10] + @staticmethod + def _payload_log_context(user_payload: dict) -> str: + telegram_id = PanelWebhookService._payload_telegram_id(user_payload) + panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload) + email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip()) + expire_at = str(user_payload.get("expireAt") or "").strip() + payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none" + return ( + f"telegramId={telegram_id or 'N/A'} " + f"panel_uuid={panel_uuid or 'N/A'} " + f"email={email or 'N/A'} " + f"expireAt={expire_at or 'N/A'} " + f"payload_keys={payload_keys}" + ) + + @staticmethod + def _mask_email(email: str) -> str: + if not email: + return "" + local_part, separator, domain = email.partition("@") + if not separator or not domain: + return "present" + visible = local_part[:2] if len(local_part) > 2 else local_part[:1] + return f"{visible}***@{domain}" + @staticmethod def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]: raw = str(user_payload.get("expireAt") or "").strip() diff --git a/tests/test_panel_webhook_routing.py b/tests/test_panel_webhook_routing.py index e9525fe..f91b36c 100644 --- a/tests/test_panel_webhook_routing.py +++ b/tests/test_panel_webhook_routing.py @@ -133,5 +133,69 @@ class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(background_seen, [("user.expired", {"telegramId": 7})]) +class _FakeSessionContext: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeSessionFactory: + def __call__(self): + return _FakeSessionContext() + + +class HandleEventLoggingTests(unittest.IsolatedAsyncioTestCase): + async def test_unsupported_event_is_logged_as_ignored(self): + service = _make_service() + + with self.assertLogs(level="INFO") as logs: + await service.handle_event( + "user.modified", + {"uuid": "panel-user-1", "email": "client@example.com"}, + ) + + message = "\n".join(logs.output) + self.assertIn("Panel webhook event user.modified ignored", message) + self.assertIn("event is not used for subscription notifications", message) + self.assertIn("panel_uuid=panel-user-1", message) + self.assertIn("email=cl***@example.com", message) + + async def test_missing_subscription_warning_includes_payload_context(self): + service = _make_service() + service.async_session_factory = _FakeSessionFactory() + + async def fake_user_for_payload(session, user_payload): + return SimpleNamespace(user_id=123, language_code=None) + + async def fake_subscription_for_payload(session, user_payload, db_user): + return None + + with ( + patch.object(service, "_user_for_payload", fake_user_for_payload), + patch.object(service, "_subscription_for_payload", fake_subscription_for_payload), + self.assertLogs(level="WARNING") as logs, + ): + await service.handle_event( + "user.expired", + { + "uuid": "panel-user-2", + "email": "person@example.com", + "expireAt": "2026-05-30T07:18:32Z", + }, + ) + + message = "\n".join(logs.output) + self.assertIn("cannot be matched to a local subscription", message) + self.assertIn("notification skipped", message) + self.assertIn("telegramId=N/A", message) + self.assertIn("panel_uuid=panel-user-2", message) + self.assertIn("email=pe***@example.com", message) + self.assertIn("expireAt=2026-05-30T07:18:32Z", message) + self.assertIn("local_user_id=123", message) + self.assertIn("subscription was deleted or not synced", message) + + if __name__ == "__main__": # pragma: no cover unittest.main()