diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 1ebffd0..2d393d4 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -77,14 +77,20 @@ SETTINGS_MANIFEST: List[SettingField] = [ "int", "general", "ID обязательного канала", - "Telegram ID канала, в котором нужно состоять.", + ( + "Telegram ID канала для проверки подписки. Если бот видит канал, " + "ссылка кнопки будет получена автоматически." + ), ), SettingField( "REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", - "Имя пользователя или invite-link.", + ( + "Необязательно: публичный @username или invite-link, " + "если ссылку нельзя получить по ID канала." + ), ), SettingField( "PANEL_API_URL", diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 44cc598..90d1cb6 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback from bot.utils.channel_subscription import ( is_required_channel_access_error, normalize_required_channel_id, + resolve_required_channel_link, ) from bot.utils.install_links import ( append_install_share_link_text, @@ -376,11 +377,12 @@ async def ensure_required_channel_subscription( ) return True - keyboard = ( - get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) - if i18n - else None + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + settings.REQUIRED_CHANNEL_LINK, ) + keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None prompt_text = translate("channel_subscription_required") diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 6bd5fde..d0a5790 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from bot.middlewares.i18n import locale_language_options +from bot.utils.channel_subscription import normalize_required_channel_link from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings @@ -718,10 +719,11 @@ def get_channel_subscription_keyboard( has_buttons = False - if channel_link: + channel_url = normalize_required_channel_link(channel_link) + if channel_url: builder.button( text=_(key="channel_subscription_join_button"), - url=channel_link, + url=channel_url, ) has_buttons = True diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index 0c760ce..f63e81f 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,7 +11,10 @@ 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 bot.utils.channel_subscription import ( + normalize_required_channel_id, + resolve_required_channel_link, +) from config.settings import Settings from db.dal import user_dal @@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): return i18n_instance.gettext(current_lang, key) return key + bot_instance = data.get("bot") or data.get("bot_instance") + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + self.settings.REQUIRED_CHANNEL_LINK, + ) keyboard = ( - get_channel_subscription_keyboard( - current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK - ) + get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link) if i18n_instance else None ) diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py index 72ba65c..4f08287 100644 --- a/backend/bot/utils/channel_subscription.py +++ b/backend/bot/utils/channel_subscription.py @@ -1,4 +1,9 @@ -from typing import Optional +import logging +import re +from typing import Any, Optional + +_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE) +_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$") def normalize_required_channel_id(value: object) -> Optional[int]: @@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]: return -int(f"100{raw_abs}") +def normalize_required_channel_link(value: object) -> Optional[str]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + if _TELEGRAM_LINK_RE.match(raw): + return raw + + raw = raw.lstrip("@").strip() + if not raw or re.search(r"\s", raw): + return None + + if raw.startswith(("t.me/", "telegram.me/")): + return f"https://{raw}" + + if raw.startswith(("+", "joinchat/", "c/")): + return f"https://t.me/{raw}" + + if _TELEGRAM_USERNAME_RE.fullmatch(raw): + return f"https://t.me/{raw}" + + return None + + +def _required_channel_link_from_chat(chat: Any) -> Optional[str]: + username = str(getattr(chat, "username", "") or "").strip().lstrip("@") + if username: + return f"https://t.me/{username}" + + invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None)) + if invite_link: + return invite_link + + return None + + +async def resolve_required_channel_link( + bot: Any, + required_channel_id: Optional[int], + configured_link: object, +) -> Optional[str]: + if bot is not None and required_channel_id: + try: + chat = await bot.get_chat(required_channel_id) + resolved_link = _required_channel_link_from_chat(chat) + if resolved_link: + return resolved_link + except Exception as error: + logging.warning( + "Failed to resolve required channel link from chat %s: %s", + required_channel_id, + error, + ) + + return normalize_required_channel_link(configured_link) + + def is_required_channel_access_error(error: BaseException) -> bool: message = str(error).lower() configuration_markers = ( diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index c3f988c..e110aed 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -108,8 +108,8 @@ | `TERMS_OF_SERVICE_URL` | Условия использования. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. | -| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | -| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | +| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. | +| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | diff --git a/tests/test_channel_subscription.py b/tests/test_channel_subscription.py index 27c3406..4940ae7 100644 --- a/tests/test_channel_subscription.py +++ b/tests/test_channel_subscription.py @@ -9,6 +9,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware from bot.utils.channel_subscription import ( is_required_channel_access_error, normalize_required_channel_id, + normalize_required_channel_link, ) @@ -21,10 +22,13 @@ class I18nStub: class FakeBot: - def __init__(self, *, status="member", error=None): + def __init__(self, *, status="member", error=None, chat=None, chat_error=None): self.status = status self.error = error + self.chat = chat + self.chat_error = chat_error self.calls = [] + self.get_chat_calls = [] async def get_chat_member(self, chat_id, user_id): self.calls.append((chat_id, user_id)) @@ -32,11 +36,17 @@ class FakeBot: raise self.error return SimpleNamespace(status=self.status) + async def get_chat(self, chat_id): + self.get_chat_calls.append(chat_id) + if self.chat_error: + raise self.chat_error + return self.chat or SimpleNamespace(username="required_channel") -def _settings(required_channel_id): + +def _settings(required_channel_id, required_channel_link="https://t.me/example"): return SimpleNamespace( REQUIRED_CHANNEL_ID=required_channel_id, - REQUIRED_CHANNEL_LINK="https://t.me/example", + REQUIRED_CHANNEL_LINK=required_channel_link, ADMIN_IDS=[], DEFAULT_LANGUAGE="en", ) @@ -70,6 +80,24 @@ class RequiredChannelIdNormalizationTests(unittest.TestCase): self.assertIsNone(normalize_required_channel_id("")) self.assertIsNone(normalize_required_channel_id(0)) + def test_normalizes_channel_links_for_join_button(self): + self.assertEqual( + normalize_required_channel_link("@required_channel"), "https://t.me/required_channel" + ) + self.assertEqual( + normalize_required_channel_link("required_channel"), "https://t.me/required_channel" + ) + self.assertEqual( + normalize_required_channel_link("t.me/required_channel"), + "https://t.me/required_channel", + ) + self.assertEqual( + normalize_required_channel_link("https://t.me/required_channel"), + "https://t.me/required_channel", + ) + self.assertEqual(normalize_required_channel_link("+inviteHash"), "https://t.me/+inviteHash") + self.assertIsNone(normalize_required_channel_link("not a valid link")) + def test_detects_channel_configuration_errors(self): self.assertTrue( is_required_channel_access_error( @@ -124,6 +152,33 @@ class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase): event.answer.assert_awaited_once_with("check failed") update_user.assert_not_awaited() + async def test_join_button_prefers_channel_link_resolved_from_required_id(self): + bot = FakeBot( + status="left", + chat=SimpleNamespace(username="required_channel", invite_link=None), + ) + event = _message_event(bot) + user = _db_user() + + with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()): + result = await ensure_required_channel_subscription( + event, + _settings(1234567890, required_channel_link="https://t.me/main_sales_bot"), + I18nStub(), + "en", + AsyncMock(), + db_user=user, + ) + + self.assertFalse(result) + self.assertEqual(bot.calls, [(-1001234567890, 42)]) + self.assertEqual(bot.get_chat_calls, [-1001234567890]) + reply_markup = event.answer.await_args.kwargs["reply_markup"] + self.assertEqual( + reply_markup.inline_keyboard[0][0].url, + "https://t.me/required_channel", + ) + class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase): async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self): @@ -146,6 +201,40 @@ class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result, "ok") handler.assert_awaited_once_with(event, data) + async def test_middleware_prompt_uses_channel_link_resolved_from_required_id(self): + middleware = ChannelSubscriptionMiddleware( + _settings(1234567890, required_channel_link="https://t.me/main_sales_bot"), + I18nStub(), + ) + handler = AsyncMock(return_value="ok") + message = SimpleNamespace(text="menu", answer=AsyncMock()) + event = SimpleNamespace(callback_query=None, message=message) + bot = FakeBot( + chat=SimpleNamespace(username="required_channel", invite_link=None), + ) + data = { + "bot": bot, + "event_from_user": SimpleNamespace(id=42), + "session": AsyncMock(), + "i18n_data": {"current_language": "en", "i18n_instance": I18nStub()}, + } + user = _db_user(verified=False, verified_for=None) + + with patch( + "bot.middlewares.channel_subscription.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ): + result = await middleware(handler, event, data) + + self.assertIsNone(result) + handler.assert_not_awaited() + self.assertEqual(bot.get_chat_calls, [-1001234567890]) + reply_markup = message.answer.await_args.kwargs["reply_markup"] + self.assertEqual( + reply_markup.inline_keyboard[0][0].url, + "https://t.me/required_channel", + ) + if __name__ == "__main__": unittest.main()