fix(bot): resolve required channel join link
This commit is contained in:
@@ -77,14 +77,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"int",
|
"int",
|
||||||
"general",
|
"general",
|
||||||
"ID обязательного канала",
|
"ID обязательного канала",
|
||||||
"Telegram ID канала, в котором нужно состоять.",
|
(
|
||||||
|
"Telegram ID канала для проверки подписки. Если бот видит канал, "
|
||||||
|
"ссылка кнопки будет получена автоматически."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"REQUIRED_CHANNEL_LINK",
|
"REQUIRED_CHANNEL_LINK",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"general",
|
||||||
"Ссылка на канал",
|
"Ссылка на канал",
|
||||||
"Имя пользователя или invite-link.",
|
(
|
||||||
|
"Необязательно: публичный @username или invite-link, "
|
||||||
|
"если ссылку нельзя получить по ID канала."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_URL",
|
"PANEL_API_URL",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback
|
|||||||
from bot.utils.channel_subscription import (
|
from bot.utils.channel_subscription import (
|
||||||
is_required_channel_access_error,
|
is_required_channel_access_error,
|
||||||
normalize_required_channel_id,
|
normalize_required_channel_id,
|
||||||
|
resolve_required_channel_link,
|
||||||
)
|
)
|
||||||
from bot.utils.install_links import (
|
from bot.utils.install_links import (
|
||||||
append_install_share_link_text,
|
append_install_share_link_text,
|
||||||
@@ -376,11 +377,12 @@ async def ensure_required_channel_subscription(
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
keyboard = (
|
channel_link = await resolve_required_channel_link(
|
||||||
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
|
bot_instance,
|
||||||
if i18n
|
required_channel_id,
|
||||||
else None
|
settings.REQUIRED_CHANNEL_LINK,
|
||||||
)
|
)
|
||||||
|
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
|
||||||
|
|
||||||
prompt_text = translate("channel_subscription_required")
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
|
|
||||||
from bot.middlewares.i18n import locale_language_options
|
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.install_links import bot_install_guide_url
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -718,10 +719,11 @@ def get_channel_subscription_keyboard(
|
|||||||
|
|
||||||
has_buttons = False
|
has_buttons = False
|
||||||
|
|
||||||
if channel_link:
|
channel_url = normalize_required_channel_link(channel_link)
|
||||||
|
if channel_url:
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="channel_subscription_join_button"),
|
text=_(key="channel_subscription_join_button"),
|
||||||
url=channel_link,
|
url=channel_url,
|
||||||
)
|
)
|
||||||
has_buttons = True
|
has_buttons = True
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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 config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
|
||||||
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
|||||||
return i18n_instance.gettext(current_lang, key)
|
return i18n_instance.gettext(current_lang, key)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
keyboard = (
|
bot_instance = data.get("bot") or data.get("bot_instance")
|
||||||
get_channel_subscription_keyboard(
|
channel_link = await resolve_required_channel_link(
|
||||||
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
bot_instance,
|
||||||
|
required_channel_id,
|
||||||
|
self.settings.REQUIRED_CHANNEL_LINK,
|
||||||
)
|
)
|
||||||
|
keyboard = (
|
||||||
|
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
|
||||||
if i18n_instance
|
if i18n_instance
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]:
|
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}")
|
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:
|
def is_required_channel_access_error(error: BaseException) -> bool:
|
||||||
message = str(error).lower()
|
message = str(error).lower()
|
||||||
configuration_markers = (
|
configuration_markers = (
|
||||||
|
|||||||
@@ -108,8 +108,8 @@
|
|||||||
| `TERMS_OF_SERVICE_URL` | Условия использования. |
|
| `TERMS_OF_SERVICE_URL` | Условия использования. |
|
||||||
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
|
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
|
||||||
| `USER_AGREEMENT_URL` | Пользовательское соглашение. |
|
| `USER_AGREEMENT_URL` | Пользовательское соглашение. |
|
||||||
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. |
|
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
|
||||||
| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. |
|
| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. |
|
||||||
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
|
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
|
||||||
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
|
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
|||||||
from bot.utils.channel_subscription import (
|
from bot.utils.channel_subscription import (
|
||||||
is_required_channel_access_error,
|
is_required_channel_access_error,
|
||||||
normalize_required_channel_id,
|
normalize_required_channel_id,
|
||||||
|
normalize_required_channel_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,10 +22,13 @@ class I18nStub:
|
|||||||
|
|
||||||
|
|
||||||
class FakeBot:
|
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.status = status
|
||||||
self.error = error
|
self.error = error
|
||||||
|
self.chat = chat
|
||||||
|
self.chat_error = chat_error
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self.get_chat_calls = []
|
||||||
|
|
||||||
async def get_chat_member(self, chat_id, user_id):
|
async def get_chat_member(self, chat_id, user_id):
|
||||||
self.calls.append((chat_id, user_id))
|
self.calls.append((chat_id, user_id))
|
||||||
@@ -32,11 +36,17 @@ class FakeBot:
|
|||||||
raise self.error
|
raise self.error
|
||||||
return SimpleNamespace(status=self.status)
|
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(
|
return SimpleNamespace(
|
||||||
REQUIRED_CHANNEL_ID=required_channel_id,
|
REQUIRED_CHANNEL_ID=required_channel_id,
|
||||||
REQUIRED_CHANNEL_LINK="https://t.me/example",
|
REQUIRED_CHANNEL_LINK=required_channel_link,
|
||||||
ADMIN_IDS=[],
|
ADMIN_IDS=[],
|
||||||
DEFAULT_LANGUAGE="en",
|
DEFAULT_LANGUAGE="en",
|
||||||
)
|
)
|
||||||
@@ -70,6 +80,24 @@ class RequiredChannelIdNormalizationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(normalize_required_channel_id(""))
|
self.assertIsNone(normalize_required_channel_id(""))
|
||||||
self.assertIsNone(normalize_required_channel_id(0))
|
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):
|
def test_detects_channel_configuration_errors(self):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
is_required_channel_access_error(
|
is_required_channel_access_error(
|
||||||
@@ -124,6 +152,33 @@ class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
event.answer.assert_awaited_once_with("check failed")
|
event.answer.assert_awaited_once_with("check failed")
|
||||||
update_user.assert_not_awaited()
|
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):
|
class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
|
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
|
||||||
@@ -146,6 +201,40 @@ class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(result, "ok")
|
self.assertEqual(result, "ok")
|
||||||
handler.assert_awaited_once_with(event, data)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user