diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index a44d2d2..44cc598 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED from bot.utils.callback_answer import safe_answer_callback +from bot.utils.channel_subscription import ( + is_required_channel_access_error, + normalize_required_channel_id, +) from bot.utils.install_links import ( append_install_share_link_text, ensure_user_install_guide_links, @@ -215,7 +219,7 @@ async def ensure_required_channel_subscription( Verify that the user is a member of the required channel (if configured). Returns True when access can proceed, False when user must subscribe first. """ - required_channel_id = settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return True @@ -279,6 +283,29 @@ async def ensure_required_channel_subscription( if status_value in allowed_statuses: is_member = True except TelegramBadRequest as bad_request: + if is_required_channel_access_error(bad_request): + logging.error( + "Required channel check failed due to channel access/configuration error " + "(configured=%s, normalized=%s): %s", + settings.REQUIRED_CHANNEL_ID, + required_channel_id, + bad_request, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + logging.info( "Required channel check: user %s not subscribed (details: %s)", user_id, diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index d6d52cc..0c760ce 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n +from bot.utils.channel_subscription import normalize_required_channel_id from config.settings import Settings from db.dal import user_dal @@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): event: Update, data: Dict[str, Any], ) -> Any: - required_channel_id = self.settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return await handler(event, data) diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py new file mode 100644 index 0000000..72ba65c --- /dev/null +++ b/backend/bot/utils/channel_subscription.py @@ -0,0 +1,40 @@ +from typing import Optional + + +def normalize_required_channel_id(value: object) -> Optional[int]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + try: + channel_id = int(raw) + except (TypeError, ValueError): + return None + + if channel_id == 0: + return None + + if channel_id > 0: + return int(f"-100{channel_id}") + + raw_abs = str(abs(channel_id)) + if raw.startswith("-100"): + return channel_id + if abs(channel_id) < 1_000_000_000: + return channel_id + return -int(f"100{raw_abs}") + + +def is_required_channel_access_error(error: BaseException) -> bool: + message = str(error).lower() + configuration_markers = ( + "chat not found", + "bot is not a member", + "not enough rights", + "have no rights", + "kicked", + ) + return any(marker in message for marker in configuration_markers) diff --git a/backend/config/settings.py b/backend/config/settings.py index 23bbfd6..bd09406 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1018,6 +1018,7 @@ class Settings(BaseSettings): "LOG_SUPPORT_THREAD_ID", "BACKUP_CHAT_ID", "BACKUP_THREAD_ID", + "REQUIRED_CHANNEL_ID", mode="before", ) @classmethod diff --git a/tests/test_channel_subscription.py b/tests/test_channel_subscription.py new file mode 100644 index 0000000..27c3406 --- /dev/null +++ b/tests/test_channel_subscription.py @@ -0,0 +1,151 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from aiogram.exceptions import TelegramBadRequest + +from bot.handlers.user.start import ensure_required_channel_subscription +from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware +from bot.utils.channel_subscription import ( + is_required_channel_access_error, + normalize_required_channel_id, +) + + +class I18nStub: + def gettext(self, _lang, key, **_kwargs): + return { + "channel_subscription_required": "subscribe first", + "channel_subscription_check_failed": "check failed", + }.get(key, key) + + +class FakeBot: + def __init__(self, *, status="member", error=None): + self.status = status + self.error = error + self.calls = [] + + async def get_chat_member(self, chat_id, user_id): + self.calls.append((chat_id, user_id)) + if self.error: + raise self.error + return SimpleNamespace(status=self.status) + + +def _settings(required_channel_id): + return SimpleNamespace( + REQUIRED_CHANNEL_ID=required_channel_id, + REQUIRED_CHANNEL_LINK="https://t.me/example", + ADMIN_IDS=[], + DEFAULT_LANGUAGE="en", + ) + + +def _message_event(bot, user_id=42): + return SimpleNamespace( + from_user=SimpleNamespace(id=user_id), + bot=bot, + answer=AsyncMock(), + ) + + +def _db_user(*, verified=False, verified_for=None): + return SimpleNamespace( + channel_subscription_verified=verified, + channel_subscription_verified_for=verified_for, + ) + + +class RequiredChannelIdNormalizationTests(unittest.TestCase): + def test_normalizes_raw_channel_ids_to_bot_api_chat_ids(self): + self.assertEqual(normalize_required_channel_id(1234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id("1234567890"), -1001234567890) + self.assertEqual(normalize_required_channel_id(-1234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id(-1001234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id(-123456789), -123456789) + + def test_ignores_empty_channel_ids(self): + self.assertIsNone(normalize_required_channel_id(None)) + self.assertIsNone(normalize_required_channel_id("")) + self.assertIsNone(normalize_required_channel_id(0)) + + def test_detects_channel_configuration_errors(self): + self.assertTrue( + is_required_channel_access_error( + TelegramBadRequest(method=None, message="Bad Request: chat not found") + ) + ) + self.assertFalse( + is_required_channel_access_error( + TelegramBadRequest(method=None, message="Bad Request: user not found") + ) + ) + + +class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase): + async def test_check_uses_normalized_channel_id_and_persists_it(self): + bot = FakeBot(status="member") + event = _message_event(bot) + user = _db_user() + + with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user: + result = await ensure_required_channel_subscription( + event, + _settings(1234567890), + I18nStub(), + "en", + AsyncMock(), + db_user=user, + ) + + self.assertTrue(result) + self.assertEqual(bot.calls, [(-1001234567890, 42)]) + payload = update_user.await_args.args[2] + self.assertTrue(payload["channel_subscription_verified"]) + self.assertEqual(payload["channel_subscription_verified_for"], -1001234567890) + + async def test_channel_access_errors_show_check_failed_without_persisting_false_status(self): + bot = FakeBot(error=TelegramBadRequest(method=None, message="Bad Request: chat not found")) + event = _message_event(bot) + user = _db_user() + + with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user: + result = await ensure_required_channel_subscription( + event, + _settings(1234567890), + I18nStub(), + "en", + AsyncMock(), + db_user=user, + ) + + self.assertFalse(result) + event.answer.assert_awaited_once_with("check failed") + update_user.assert_not_awaited() + + +class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self): + middleware = ChannelSubscriptionMiddleware(_settings(1234567890), I18nStub()) + handler = AsyncMock(return_value="ok") + event = SimpleNamespace(callback_query=None, message=None) + data = { + "event_from_user": SimpleNamespace(id=42), + "session": AsyncMock(), + "i18n_data": {"current_language": "en", "i18n_instance": I18nStub()}, + } + user = _db_user(verified=True, verified_for=-1001234567890) + + with patch( + "bot.middlewares.channel_subscription.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ): + result = await middleware(handler, event, data) + + self.assertEqual(result, "ok") + handler.assert_awaited_once_with(event, data) + + +if __name__ == "__main__": + unittest.main()