diff --git a/aiogram/client/telegram.py b/aiogram/client/telegram.py index db653249..dfededb8 100644 --- a/aiogram/client/telegram.py +++ b/aiogram/client/telegram.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True) class TelegramAPIServer: """ Base config for API Endpoints @@ -9,10 +9,11 @@ class TelegramAPIServer: base: str file: str + is_local: bool = False def api_url(self, token: str, method: str) -> str: """ - Generate URL for methods + Generate URL for API methods :param token: Bot token :param method: API method name (case insensitive) @@ -30,9 +31,15 @@ class TelegramAPIServer: """ return self.file.format(token=token, path=path) + @classmethod + def from_base(cls, base: str, is_local: bool = False) -> "TelegramAPIServer": + base = base.rstrip("/") + return cls( + base=f"{base}/bot{{token}}/{{method}}", + file=f"{base}/file/bot{{token}}/{{path}}", + is_local=is_local, + ) + # Main API server -PRODUCTION = TelegramAPIServer( - base="https://api.telegram.org/bot{token}/{method}", - file="https://api.telegram.org/file/bot{token}/{path}", -) +PRODUCTION = TelegramAPIServer.from_base("https://api.telegram.org") diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index a9b8aab7..ff0bf1a7 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -247,7 +247,7 @@ class Dispatcher(Router): raise async def feed_webhook_update( - self, bot: Bot, update: Union[Update, Dict[str, Any]], _timeout: int = 55, **kwargs: Any + self, bot: Bot, update: Union[Update, Dict[str, Any]], _timeout: float = 55, **kwargs: Any ) -> Optional[Dict[str, Any]]: if not isinstance(update, Update): # Allow to use raw updates update = Update(**update) diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index db074975..6377223b 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: # pragma: no cover from aiogram.types import MessageEntity __all__ = ( - "TextDecoration", "HtmlDecoration", "MarkdownDecoration", + "TextDecoration", "html_decoration", "markdown_decoration", ) @@ -57,14 +57,15 @@ class TextDecoration(ABC): """ result = "".join( self._unparse_entities( - text, sorted(entities, key=lambda item: item.offset) if entities else [] + self._add_surrogates(text), + sorted(entities, key=lambda item: item.offset) if entities else [], ) ) return result def _unparse_entities( self, - text: str, + text: bytes, entities: List[MessageEntity], offset: Optional[int] = None, length: Optional[int] = None, @@ -74,21 +75,31 @@ class TextDecoration(ABC): length = length or len(text) for index, entity in enumerate(entities): - if entity.offset < offset: + if entity.offset * 2 < offset: continue - if entity.offset > offset: - yield self.quote(text[offset : entity.offset]) - start = entity.offset - offset = entity.offset + entity.length + if entity.offset * 2 > offset: + yield self.quote(self._remove_surrogates(text[offset : entity.offset * 2])) + start = entity.offset * 2 + offset = entity.offset * 2 + entity.length * 2 - sub_entities = list(filter(lambda e: e.offset < (offset or 0), entities[index + 1 :])) + sub_entities = list( + filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :]) + ) yield self.apply_entity( entity, "".join(self._unparse_entities(text, sub_entities, offset=start, length=offset)), ) if offset < length: - yield self.quote(text[offset:length]) + yield self.quote(self._remove_surrogates(text[offset:length])) + + @staticmethod + def _add_surrogates(text: str) -> bytes: + return text.encode("utf-16-le") + + @staticmethod + def _remove_surrogates(text: bytes) -> str: + return text.decode("utf-16-le") @abstractmethod def link(self, value: str, link: str) -> str: # pragma: no cover @@ -153,11 +164,11 @@ class HtmlDecoration(TextDecoration): return f"{value}" def quote(self, value: str) -> str: - return html.escape(value) + return html.escape(value, quote=False) class MarkdownDecoration(TextDecoration): - MARKDOWN_QUOTE_PATTERN: Pattern[str] = re.compile(r"([_*\[\]()~`>#+\-|{}.!])") + MARKDOWN_QUOTE_PATTERN: Pattern[str] = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])") def link(self, value: str, link: str) -> str: return f"[{value}]({link})" @@ -166,7 +177,7 @@ class MarkdownDecoration(TextDecoration): return f"*{value}*" def italic(self, value: str) -> str: - return f"_{value}_\r" + return f"_\r{value}_\r" def code(self, value: str) -> str: return f"`{value}`" @@ -178,7 +189,7 @@ class MarkdownDecoration(TextDecoration): return f"```{language}\n{value}\n```" def underline(self, value: str) -> str: - return f"__{value}__" + return f"__\r{value}__\r" def strikethrough(self, value: str) -> str: return f"~{value}~" diff --git a/tests/test_api/test_client/test_api_server.py b/tests/test_api/test_client/test_api_server.py index d7f7025d..6faf0c0f 100644 --- a/tests/test_api/test_client/test_api_server.py +++ b/tests/test_api/test_client/test_api_server.py @@ -1,4 +1,4 @@ -from aiogram.client.telegram import PRODUCTION +from aiogram.client.telegram import PRODUCTION, TelegramAPIServer class TestAPIServer: @@ -9,3 +9,13 @@ class TestAPIServer: def test_file_url(self): file_url = PRODUCTION.file_url(token="42:TEST", path="path") assert file_url == "https://api.telegram.org/file/bot42:TEST/path" + + def test_from_base(self): + local_server = TelegramAPIServer.from_base("http://localhost:8081", is_local=True) + + method_url = local_server.api_url("42:TEST", method="apiMethod") + file_url = local_server.file_url(token="42:TEST", path="path") + + assert method_url == "http://localhost:8081/bot42:TEST/apiMethod" + assert file_url == "http://localhost:8081/file/bot42:TEST/path" + assert local_server.is_local diff --git a/tests/test_api/test_methods/test_close.py b/tests/test_api/test_methods/test_close.py index ce3ce43d..c497520e 100644 --- a/tests/test_api/test_methods/test_close.py +++ b/tests/test_api/test_methods/test_close.py @@ -1,4 +1,5 @@ import pytest + from aiogram.methods import Close, Request from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_copy_message.py b/tests/test_api/test_methods/test_copy_message.py index f0f1812c..d380f24a 100644 --- a/tests/test_api/test_methods/test_copy_message.py +++ b/tests/test_api/test_methods/test_copy_message.py @@ -1,4 +1,5 @@ import pytest + from aiogram.methods import CopyMessage, Request from aiogram.types import MessageId from tests.mocked_bot import MockedBot @@ -10,7 +11,9 @@ class TestCopyMessage: prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) response: MessageId = await CopyMessage( - chat_id=42, from_chat_id=42, message_id=42, + chat_id=42, + from_chat_id=42, + message_id=42, ) request: Request = bot.get_request() assert request.method == "copyMessage" @@ -22,7 +25,9 @@ class TestCopyMessage: prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) response: MessageId = await bot.copy_message( - chat_id=42, from_chat_id=42, message_id=42, + chat_id=42, + from_chat_id=42, + message_id=42, ) request: Request = bot.get_request() assert request.method == "copyMessage" diff --git a/tests/test_api/test_methods/test_log_out.py b/tests/test_api/test_methods/test_log_out.py index bc9149f3..e000540f 100644 --- a/tests/test_api/test_methods/test_log_out.py +++ b/tests/test_api/test_methods/test_log_out.py @@ -1,4 +1,5 @@ import pytest + from aiogram.methods import LogOut, Request from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_unpin_all_chat_messages.py b/tests/test_api/test_methods/test_unpin_all_chat_messages.py index edb2adfc..48348dfd 100644 --- a/tests/test_api/test_methods/test_unpin_all_chat_messages.py +++ b/tests/test_api/test_methods/test_unpin_all_chat_messages.py @@ -1,4 +1,5 @@ import pytest + from aiogram.methods import Request, UnpinAllChatMessages from tests.mocked_bot import MockedBot @@ -8,7 +9,9 @@ class TestUnpinAllChatMessages: async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) - response: bool = await UnpinAllChatMessages(chat_id=42,) + response: bool = await UnpinAllChatMessages( + chat_id=42, + ) request: Request = bot.get_request() assert request.method == "unpinAllChatMessages" # assert request.data == {} @@ -18,7 +21,9 @@ class TestUnpinAllChatMessages: async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) - response: bool = await bot.unpin_all_chat_messages(chat_id=42,) + response: bool = await bot.unpin_all_chat_messages( + chat_id=42, + ) request: Request = bot.get_request() assert request.method == "unpinAllChatMessages" # assert request.data == {} diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index e9cfc495..a99b1bd0 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -37,12 +37,12 @@ except ImportError: async def simple_message_handler(message: Message): - await asyncio.sleep(1.5) + await asyncio.sleep(0.2) return message.answer("ok") async def invalid_message_handler(message: Message): - await asyncio.sleep(1.5) + await asyncio.sleep(0.2) raise Exception(42) @@ -578,21 +578,11 @@ class TestDispatcher: dispatcher = Dispatcher() dispatcher.message.register(simple_message_handler) - response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=2) + response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=0.3) assert isinstance(response, dict) assert response["method"] == "sendMessage" assert response["text"] == "ok" - # @pytest.mark.asyncio - # async def test_feed_webhook_update_fast_process_error(self, bot: MockedBot): - # dispatcher = Dispatcher() - # dispatcher.message_handler.register(invalid_message_handler) - # - # response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=2) - # assert isinstance(response, dict) - # assert response["method"] == "sendMessage" - # assert response["text"] == "ok" - @pytest.mark.asyncio async def test_feed_webhook_update_slow_process(self, bot: MockedBot, recwarn): warnings.simplefilter("always") @@ -604,9 +594,9 @@ class TestDispatcher: "aiogram.dispatcher.dispatcher.Dispatcher._silent_call_request", new_callable=CoroutineMock, ) as mocked_silent_call_request: - response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=1) + response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=0.1) assert response is None - await asyncio.sleep(1) + await asyncio.sleep(0.2) mocked_silent_call_request.assert_awaited() @pytest.mark.asyncio @@ -616,9 +606,9 @@ class TestDispatcher: dispatcher = Dispatcher() dispatcher.message.register(invalid_message_handler) - response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=1) + response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=0.1) assert response is None - await asyncio.sleep(1) + await asyncio.sleep(0.1) log_records = [rec.message for rec in caplog.records] assert "Cause exception while process update" in log_records[0] diff --git a/tests/test_utils/test_markdown.py b/tests/test_utils/test_markdown.py index 792c1bb4..12e44ccf 100644 --- a/tests/test_utils/test_markdown.py +++ b/tests/test_utils/test_markdown.py @@ -31,13 +31,13 @@ class TestMarkdown: [text, ("test", "test"), None, "test test"], [bold, ("test", "test"), " ", "*test test*"], [hbold, ("test", "test"), " ", "test test"], - [italic, ("test", "test"), " ", "_test test_\r"], + [italic, ("test", "test"), " ", "_\rtest test_\r"], [hitalic, ("test", "test"), " ", "test test"], [code, ("test", "test"), " ", "`test test`"], [hcode, ("test", "test"), " ", "test test"], [pre, ("test", "test"), " ", "```test test```"], [hpre, ("test", "test"), " ", "
test test
"], - [underline, ("test", "test"), " ", "__test test__"], + [underline, ("test", "test"), " ", "__\rtest test__\r"], [hunderline, ("test", "test"), " ", "test test"], [strikethrough, ("test", "test"), " ", "~test test~"], [hstrikethrough, ("test", "test"), " ", "test test"], diff --git a/tests/test_utils/test_text_decorations.py b/tests/test_utils/test_text_decorations.py index a1a48b87..6cb5105d 100644 --- a/tests/test_utils/test_text_decorations.py +++ b/tests/test_utils/test_text_decorations.py @@ -53,7 +53,7 @@ class TestTextDecoration: 'test', ], [markdown_decoration, MessageEntity(type="bold", offset=0, length=5), "*test*"], - [markdown_decoration, MessageEntity(type="italic", offset=0, length=5), "_test_\r"], + [markdown_decoration, MessageEntity(type="italic", offset=0, length=5), "_\rtest_\r"], [markdown_decoration, MessageEntity(type="code", offset=0, length=5), "`test`"], [markdown_decoration, MessageEntity(type="pre", offset=0, length=5), "```test```"], [ @@ -61,7 +61,11 @@ class TestTextDecoration: MessageEntity(type="pre", offset=0, length=5, language="python"), "```python\ntest\n```", ], - [markdown_decoration, MessageEntity(type="underline", offset=0, length=5), "__test__"], + [ + markdown_decoration, + MessageEntity(type="underline", offset=0, length=5), + "__\rtest__\r", + ], [ markdown_decoration, MessageEntity(type="strikethrough", offset=0, length=5), @@ -210,7 +214,7 @@ class TestTextDecoration: [ html_decoration, "test te👍🏿st test", - [MessageEntity(type="bold", offset=5, length=6, url=None, user=None)], + [MessageEntity(type="bold", offset=5, length=8, url=None, user=None)], "test te👍🏿st test", ], [