From a42252b5c6c898378c221bc01075e9627e95e09e Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sun, 24 Nov 2019 00:40:11 +0300 Subject: [PATCH 1/9] #238 Added deep linking feature --- aiogram/utils/deep_linking.py | 94 +++++++++++++++++++++++++++ docs/source/utils/deep_linking.rst | 6 ++ tests/test_utils/test_deep_linking.py | 75 +++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 aiogram/utils/deep_linking.py create mode 100644 docs/source/utils/deep_linking.rst create mode 100644 tests/test_utils/test_deep_linking.py diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py new file mode 100644 index 00000000..f01c4ed4 --- /dev/null +++ b/aiogram/utils/deep_linking.py @@ -0,0 +1,94 @@ +""" +Deep linking + +Telegram bots have a deep linking mechanism, that allows for passing additional +parameters to the bot on startup. It could be a command that launches the bot — or +an auth token to connect the user's Telegram account to their account on some +external service. + +You can read detailed description in the source: +https://core.telegram.org/bots#deep-linking + +We have add some utils to get deep links more handy. + +Basic link example: +>>> from aiogram.utils.deep_linking import get_start_link +>>> link = await get_start_link('foo') # result: 'https://t.me/MyBot?start=foo' + +Encoded link example: +>>> from aiogram.utils.deep_linking import get_start_link, decode_payload +>>> link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v' +>>> data = decode_payload('Zm9v') # result: 'foo' + +""" + + +async def get_start_link(payload: str, encode=False) -> str: + """ + Use this method to handy get 'start' deep link with your payload. + If you need to encode payload or pass special characters - set encode as True + + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + return await _create_link('start', payload, encode) + + +async def get_startgroup_link(payload: str, encode=False) -> str: + """ + Use this method to handy get 'startgroup' deep link with your payload. + If you need to encode payload or pass special characters - set encode as True + + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + return await _create_link('startgroup', payload, encode) + + +async def _create_link(link_type, payload: str, encode=False): + bot = await _get_bot_user() + payload = filter_payload(payload) + if encode: + payload = encode_payload(payload) + return f'https://t.me/{bot.username}?{link_type}={payload}' + + +def encode_payload(payload: str) -> str: + """ Encode payload with URL-safe base64url. """ + from base64 import urlsafe_b64encode + result: bytes = urlsafe_b64encode(payload.encode()) + return result.decode() + + +def decode_payload(payload: str) -> str: + """ Decode payload with URL-safe base64url. """ + from base64 import urlsafe_b64decode + result: bytes = urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)) + return result.decode() + + +def filter_payload(payload: str) -> str: + """ Convert payload to text and search for not allowed symbols. """ + import re + + # convert to string + if not isinstance(payload, str): + payload = str(payload) + + # search for not allowed characters + if re.search(r'[^_A-z0-9-]', payload): + message = ('Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. ' + 'We recommend to encode parameters with binary and other ' + 'types of content.') + raise ValueError(message) + + return payload + + +async def _get_bot_user(): + """ Get current user of bot. """ + from ..bot import Bot + bot = Bot.get_current() + return await bot.me diff --git a/docs/source/utils/deep_linking.rst b/docs/source/utils/deep_linking.rst new file mode 100644 index 00000000..e00e0d20 --- /dev/null +++ b/docs/source/utils/deep_linking.rst @@ -0,0 +1,6 @@ +============ +Deep linking +============ + +.. automodule:: aiogram.utils.deep_linking + :members: diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py new file mode 100644 index 00000000..f6978c41 --- /dev/null +++ b/tests/test_utils/test_deep_linking.py @@ -0,0 +1,75 @@ +import pytest + +from aiogram.utils.deep_linking import decode_payload, encode_payload, filter_payload +from aiogram.utils.deep_linking import get_start_link, get_startgroup_link +from tests.types import dataset + +# enable asyncio mode +pytestmark = pytest.mark.asyncio + +PAYLOADS = [ + 'foo', + 'AAbbCCddEEff1122334455', + 'aaBBccDDeeFF5544332211', + -12345678901234567890, + 12345678901234567890, +] + +WRONG_PAYLOADS = [ + '@BotFather', + 'spaces spaces spaces', + 1234567890123456789.0, +] + + +@pytest.fixture(params=PAYLOADS, name='payload') +def payload_fixture(request): + return request.param + + +@pytest.fixture(params=WRONG_PAYLOADS, name='wrong_payload') +def wrong_payload_fixture(request): + return request.param + + +@pytest.fixture(autouse=True) +def get_bot_user_fixture(monkeypatch): + """ Monkey patching of bot.me calling. """ + from aiogram.utils import deep_linking + + async def get_bot_user_mock(): + from aiogram.types import User + return User(**dataset.USER) + + monkeypatch.setattr(deep_linking, '_get_bot_user', get_bot_user_mock) + + +class TestDeepLinking: + async def test_get_start_link(self, payload): + link = await get_start_link(payload) + assert link == f'https://t.me/{dataset.USER["username"]}?start={payload}' + + async def test_wrong_symbols(self, wrong_payload): + with pytest.raises(ValueError): + await get_start_link(wrong_payload) + + async def test_get_startgroup_link(self, payload): + link = await get_startgroup_link(payload) + assert link == f'https://t.me/{dataset.USER["username"]}?startgroup={payload}' + + async def test_filter_encode_and_decode(self, payload): + _payload = filter_payload(payload) + encoded = encode_payload(_payload) + print(encoded) + decoded = decode_payload(encoded) + assert decoded == str(payload) + + async def test_get_start_link_with_encoding(self, payload): + # define link + link = await get_start_link(payload, encode=True) + + # define reference link + payload = filter_payload(payload) + encoded_payload = encode_payload(payload) + + assert link == f'https://t.me/{dataset.USER["username"]}?start={encoded_payload}' From 1305a06b246ce0c86ff0ddf5d232cc14e8d32120 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sun, 24 Nov 2019 01:08:06 +0300 Subject: [PATCH 2/9] #238 Removed prints and fixed example --- aiogram/utils/deep_linking.py | 3 ++- tests/test_utils/test_deep_linking.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py index f01c4ed4..2b420cf8 100644 --- a/aiogram/utils/deep_linking.py +++ b/aiogram/utils/deep_linking.py @@ -18,7 +18,8 @@ Basic link example: Encoded link example: >>> from aiogram.utils.deep_linking import get_start_link, decode_payload >>> link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v' ->>> data = decode_payload('Zm9v') # result: 'foo' +>>> # and decode it back: +>>> payload = decode_payload('Zm9v') # result: 'foo' """ diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py index f6978c41..a1d01e4e 100644 --- a/tests/test_utils/test_deep_linking.py +++ b/tests/test_utils/test_deep_linking.py @@ -60,7 +60,6 @@ class TestDeepLinking: async def test_filter_encode_and_decode(self, payload): _payload = filter_payload(payload) encoded = encode_payload(_payload) - print(encoded) decoded = decode_payload(encoded) assert decoded == str(payload) From 58f9ca5802f25902912dbc932ccec2d8b45ae212 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Fri, 29 Nov 2019 23:24:55 +0300 Subject: [PATCH 3/9] #238 Deep linking implemented to CommandStart filter --- aiogram/dispatcher/filters/builtin.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 55ed63e5..ebbf4068 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -140,7 +140,9 @@ class CommandStart(Command): This filter based on :obj:`Command` filter but can handle only ``/start`` command. """ - def __init__(self, deep_link: typing.Optional[typing.Union[str, re.Pattern]] = None): + def __init__(self, + deep_link: typing.Optional[typing.Union[str, typing.Pattern[str]]] = None, + encoded: bool = False): """ Also this filter can handle `deep-linking `_ arguments. @@ -151,9 +153,11 @@ class CommandStart(Command): @dp.message_handler(CommandStart(re.compile(r'ref-([\\d]+)'))) :param deep_link: string or compiled regular expression (by ``re.compile(...)``). + :param encoded: set True if you're waiting for encoded payload (default - False). """ super().__init__(['start']) self.deep_link = deep_link + self.encoded = encoded async def check(self, message: types.Message): """ @@ -162,18 +166,21 @@ class CommandStart(Command): :param message: :return: """ + from ...utils.deep_linking import decode_payload check = await super().check(message) if check and self.deep_link is not None: - if not isinstance(self.deep_link, re.Pattern): - return message.get_args() == self.deep_link + payload = decode_payload(message.get_args()) if self.encoded else message.get_args() - match = self.deep_link.match(message.get_args()) + if not isinstance(self.deep_link, typing.Pattern): + return payload == self.deep_link + + match = self.deep_link.match(payload) if match: return {'deep_link': match} return False - return check + return check is not False class CommandHelp(Command): @@ -244,7 +251,7 @@ class Text(Filter): raise ValueError(f"No one mode is specified!") equals, contains, endswith, startswith = map(lambda e: [e] if isinstance(e, str) or isinstance(e, LazyProxy) - else e, + else e, (equals, contains, endswith, startswith)) self.equals = equals self.contains = contains @@ -370,7 +377,7 @@ class Regexp(Filter): """ def __init__(self, regexp): - if not isinstance(regexp, re.Pattern): + if not isinstance(regexp, typing.Pattern): regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) self.regexp = regexp From 41191721f640ca052124e9a0ce09566014874c5a Mon Sep 17 00:00:00 2001 From: Oleg A Date: Fri, 29 Nov 2019 23:25:46 +0300 Subject: [PATCH 4/9] #238 Added CommandStart filter tests --- tests/test_filters.py | 49 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index 609db736..ddb3dfc8 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,8 +1,11 @@ import pytest -from aiogram.dispatcher.filters import Text +from aiogram.dispatcher.filters import Text, CommandStart from aiogram.types import Message, CallbackQuery, InlineQuery, Poll +# enable asyncio mode +pytestmark = pytest.mark.asyncio + def data_sample_1(): return [ @@ -22,15 +25,16 @@ def data_sample_1(): ('EXample_string', 'not_example_string'), ] + class TestTextFilter: - async def _run_check(self, check, test_text): + @staticmethod + async def _run_check(check, test_text): assert await check(Message(text=test_text)) assert await check(CallbackQuery(data=test_text)) assert await check(InlineQuery(query=test_text)) assert await check(Poll(question=test_text)) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_prefix, test_text", data_sample_1()) async def test_startswith(self, test_prefix, test_text, ignore_case): @@ -49,7 +53,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_prefix_list, test_text", [ (['not_example', ''], ''), @@ -83,7 +86,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_postfix, test_text", data_sample_1()) async def test_endswith(self, test_postfix, test_text, ignore_case): @@ -102,7 +104,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_postfix_list, test_text", [ (['', 'not_example'], ''), @@ -133,9 +134,9 @@ class TestTextFilter: _test_text = test_text return result is any(map(_test_text.endswith, _test_postfix_list)) + await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_string, test_text", [ ('', ''), @@ -169,7 +170,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_filter_list, test_text", [ (['a', 'ab', 'abc'], 'A'), @@ -193,7 +193,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_filter_text, test_text", [ ('', ''), @@ -222,7 +221,6 @@ class TestTextFilter: await self._run_check(check, test_text) - @pytest.mark.asyncio @pytest.mark.parametrize('ignore_case', (True, False)) @pytest.mark.parametrize("test_filter_list, test_text", [ (['new_string', ''], ''), @@ -261,3 +259,34 @@ class TestTextFilter: await check(CallbackQuery(data=test_text)) await check(InlineQuery(query=test_text)) await check(Poll(question=test_text)) + + +class TestCommandStart: + START = '/start' + GOOD = 'foo' + BAD = 'bar' + ENCODED = 'Zm9v' + + async def test_start_command_without_payload(self): + test_filter = CommandStart() # empty filter + message = Message(text=self.START) + result = await test_filter.check(message) + assert result is True + + async def test_start_command_payload_is_matched(self): + test_filter = CommandStart(deep_link=self.GOOD) + message = Message(text=f'{self.START} {self.GOOD}') + result = await test_filter.check(message) + assert result is True + + async def test_start_command_payload_is_not_matched(self): + test_filter = CommandStart(deep_link=self.GOOD) + message = Message(text=f'{self.START} {self.BAD}') + result = await test_filter.check(message) + assert result is False + + async def test_start_command_payload_is_encoded(self): + test_filter = CommandStart(deep_link=self.GOOD, encoded=True) + message = Message(text=f'{self.START} {self.ENCODED}') + result = await test_filter.check(message) + assert result is True From 52f35058db111224d70c5b9e4fe1380d39959e21 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Fri, 29 Nov 2019 23:27:19 +0300 Subject: [PATCH 5/9] #238 Formatted deep linking docs --- aiogram/utils/deep_linking.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py index 2b420cf8..acb105da 100644 --- a/aiogram/utils/deep_linking.py +++ b/aiogram/utils/deep_linking.py @@ -12,14 +12,20 @@ https://core.telegram.org/bots#deep-linking We have add some utils to get deep links more handy. Basic link example: ->>> from aiogram.utils.deep_linking import get_start_link ->>> link = await get_start_link('foo') # result: 'https://t.me/MyBot?start=foo' + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link + link = await get_start_link('foo') # result: 'https://t.me/MyBot?start=foo' Encoded link example: ->>> from aiogram.utils.deep_linking import get_start_link, decode_payload ->>> link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v' ->>> # and decode it back: ->>> payload = decode_payload('Zm9v') # result: 'foo' + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link, decode_payload + link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v' + # and decode it back: + payload = decode_payload('Zm9v') # result: 'foo' """ From 746eead0dac568f9aa2114f4f4a844a07eb6242b Mon Sep 17 00:00:00 2001 From: Oleg A Date: Fri, 29 Nov 2019 23:46:38 +0300 Subject: [PATCH 6/9] #238 Fixed deep_link = None case --- aiogram/dispatcher/filters/builtin.py | 2 +- tests/test_filters.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index ebbf4068..7f84c964 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -180,7 +180,7 @@ class CommandStart(Command): return {'deep_link': match} return False - return check is not False + return {'deep_link': None} class CommandHelp(Command): diff --git a/tests/test_filters.py b/tests/test_filters.py index ddb3dfc8..37f14129 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -271,7 +271,7 @@ class TestCommandStart: test_filter = CommandStart() # empty filter message = Message(text=self.START) result = await test_filter.check(message) - assert result is True + assert result is not False async def test_start_command_payload_is_matched(self): test_filter = CommandStart(deep_link=self.GOOD) From 768407eb95d2fca6f6d50e4d301ad7ce131293ba Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sat, 30 Nov 2019 00:16:59 +0300 Subject: [PATCH 7/9] #238 Fixed getting deep_link not by pattern --- aiogram/dispatcher/filters/builtin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 7f84c964..f6aeaa14 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -173,7 +173,7 @@ class CommandStart(Command): payload = decode_payload(message.get_args()) if self.encoded else message.get_args() if not isinstance(self.deep_link, typing.Pattern): - return payload == self.deep_link + return False if payload != self.deep_link else {'deep_link': payload} match = self.deep_link.match(payload) if match: From c23c7a2025f6cbfe71a627d7151ab603d8915120 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sat, 30 Nov 2019 00:19:08 +0300 Subject: [PATCH 8/9] #238 Improved deep_link test cases --- tests/test_filters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index 37f14129..38d4cc3f 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -271,13 +271,13 @@ class TestCommandStart: test_filter = CommandStart() # empty filter message = Message(text=self.START) result = await test_filter.check(message) - assert result is not False + assert result == {'deep_link': None} async def test_start_command_payload_is_matched(self): test_filter = CommandStart(deep_link=self.GOOD) message = Message(text=f'{self.START} {self.GOOD}') result = await test_filter.check(message) - assert result is True + assert result == {'deep_link': self.GOOD} async def test_start_command_payload_is_not_matched(self): test_filter = CommandStart(deep_link=self.GOOD) @@ -289,4 +289,4 @@ class TestCommandStart: test_filter = CommandStart(deep_link=self.GOOD, encoded=True) message = Message(text=f'{self.START} {self.ENCODED}') result = await test_filter.check(message) - assert result is True + assert result == {'deep_link': self.GOOD} From 5489e4cc18f32df47ea9af50b775ac2144d76214 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sat, 30 Nov 2019 00:21:38 +0300 Subject: [PATCH 9/9] #238 Added /start Pattern test cases --- tests/test_filters.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_filters.py b/tests/test_filters.py index 38d4cc3f..0592f31b 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,3 +1,6 @@ +import re +from typing import Match + import pytest from aiogram.dispatcher.filters import Text, CommandStart @@ -265,6 +268,8 @@ class TestCommandStart: START = '/start' GOOD = 'foo' BAD = 'bar' + GOOD_PATTERN = re.compile(r'^f..$') + BAD_PATTERN = re.compile(r'^b..$') ENCODED = 'Zm9v' async def test_start_command_without_payload(self): @@ -285,6 +290,20 @@ class TestCommandStart: result = await test_filter.check(message) assert result is False + async def test_start_command_payload_pattern_is_matched(self): + test_filter = CommandStart(deep_link=self.GOOD_PATTERN) + message = Message(text=f'{self.START} {self.GOOD}') + result = await test_filter.check(message) + assert isinstance(result, dict) + match = result.get('deep_link') + assert isinstance(match, Match) + + async def test_start_command_payload_pattern_is_not_matched(self): + test_filter = CommandStart(deep_link=self.BAD_PATTERN) + message = Message(text=f'{self.START} {self.GOOD}') + result = await test_filter.check(message) + assert result is False + async def test_start_command_payload_is_encoded(self): test_filter = CommandStart(deep_link=self.GOOD, encoded=True) message = Message(text=f'{self.START} {self.ENCODED}')