From 27e02e007c9f658a069cce2b65c214e6281503cf Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 7 Apr 2020 00:59:59 +0300 Subject: [PATCH 01/39] Bump version --- README.md | 2 +- README.rst | 2 +- aiogram/__init__.py | 4 ++-- aiogram/bot/api.py | 2 +- docs/source/index.rst | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6af84f0b..a4c5b5fc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.6-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.7-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) diff --git a/README.rst b/README.rst index 39c5ac25..6d5d1f69 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ AIOGramBot :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.6-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.7-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 169eed5f..aabf331e 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -38,5 +38,5 @@ __all__ = [ 'utils' ] -__version__ = '2.6.1' -__api_version__ = '4.6' +__version__ = '2.7' +__api_version__ = '4.7' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index f0553644..49ad849b 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -153,7 +153,7 @@ class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api - List is updated to Bot API 4.6 + List is updated to Bot API 4.7 """ mode = HelperMode.lowerCamelCase diff --git a/docs/source/index.rst b/docs/source/index.rst index 71ed04d9..fb1c9595 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,7 +22,7 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.6-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.7-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API From 70767111c4ca74961103eae0b39f09f64dd62026 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 31 May 2020 17:49:33 +0300 Subject: [PATCH 02/39] fix: add support init fields from parent object in KeyboardButton (#344) * fix: add support init fields from parent object in KeyboardButton * fix: add tests --- aiogram/types/reply_keyboard.py | 6 ++++-- tests/types/dataset.py | 5 +++++ tests/types/test_reply_keyboard.py | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 tests/types/test_reply_keyboard.py diff --git a/aiogram/types/reply_keyboard.py b/aiogram/types/reply_keyboard.py index ced20417..ffe07ae1 100644 --- a/aiogram/types/reply_keyboard.py +++ b/aiogram/types/reply_keyboard.py @@ -111,11 +111,13 @@ class KeyboardButton(base.TelegramObject): def __init__(self, text: base.String, request_contact: base.Boolean = None, request_location: base.Boolean = None, - request_poll: KeyboardButtonPollType = None): + request_poll: KeyboardButtonPollType = None, + **kwargs): super(KeyboardButton, self).__init__(text=text, request_contact=request_contact, request_location=request_location, - request_poll=request_poll) + request_poll=request_poll, + **kwargs) class ReplyKeyboardRemove(base.TelegramObject): diff --git a/tests/types/dataset.py b/tests/types/dataset.py index 310024cb..739e8e2c 100644 --- a/tests/types/dataset.py +++ b/tests/types/dataset.py @@ -457,3 +457,8 @@ WEBHOOK_INFO = { "has_custom_certificate": False, "pending_update_count": 0, } + +REPLY_KEYBOARD_MARKUP = { + "keyboard": [[{"text": "something here"}]], + "resize_keyboard": True, +} diff --git a/tests/types/test_reply_keyboard.py b/tests/types/test_reply_keyboard.py new file mode 100644 index 00000000..ae0b6d9e --- /dev/null +++ b/tests/types/test_reply_keyboard.py @@ -0,0 +1,12 @@ +from aiogram import types +from .dataset import REPLY_KEYBOARD_MARKUP + +reply_keyboard = types.ReplyKeyboardMarkup(**REPLY_KEYBOARD_MARKUP) + + +def test_serialize(): + assert reply_keyboard.to_python() == REPLY_KEYBOARD_MARKUP + + +def test_deserialize(): + assert reply_keyboard.to_object(reply_keyboard.to_python()) == reply_keyboard From de13dbf454826f5e2b552db3730605c2f10dc489 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 8 Jun 2020 20:19:05 +0300 Subject: [PATCH 03/39] AIOG-T-61 Telegram Bot API 4.9 --- aiogram/types/dice.py | 1 + aiogram/types/inline_query_result.py | 2 ++ aiogram/types/message.py | 1 + 3 files changed, 4 insertions(+) diff --git a/aiogram/types/dice.py b/aiogram/types/dice.py index 6dfb190f..7b3f1727 100644 --- a/aiogram/types/dice.py +++ b/aiogram/types/dice.py @@ -16,3 +16,4 @@ class Dice(base.TelegramObject): class DiceEmoji: DICE = '🎲' DART = '🎯' + BASKETBALL = '🏀' diff --git a/aiogram/types/inline_query_result.py b/aiogram/types/inline_query_result.py index cf26f8a4..fccaa2a1 100644 --- a/aiogram/types/inline_query_result.py +++ b/aiogram/types/inline_query_result.py @@ -118,6 +118,7 @@ class InlineQueryResultGif(InlineQueryResult): gif_height: base.Integer = fields.Field() gif_duration: base.Integer = fields.Field() thumb_url: base.String = fields.Field() + thumb_mime_type: base.String = fields.Field() title: base.String = fields.Field() caption: base.String = fields.Field() input_message_content: InputMessageContent = fields.Field(base=InputMessageContent) @@ -157,6 +158,7 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): mpeg4_height: base.Integer = fields.Field() mpeg4_duration: base.Integer = fields.Field() thumb_url: base.String = fields.Field() + thumb_mime_type: base.String = fields.Field() title: base.String = fields.Field() caption: base.String = fields.Field() input_message_content: InputMessageContent = fields.Field(base=InputMessageContent) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index ddbccde6..9b9c0f82 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -51,6 +51,7 @@ class Message(base.TelegramObject): forward_signature: base.String = fields.Field() forward_date: datetime.datetime = fields.DateTimeField() reply_to_message: Message = fields.Field(base='Message') + via_bot: User = fields.Field(base=User) edit_date: datetime.datetime = fields.DateTimeField() media_group_id: base.String = fields.Field() author_signature: base.String = fields.Field() From 50b5768759102d847efe9381dcd358de30d49cc2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 8 Jun 2020 20:20:43 +0300 Subject: [PATCH 04/39] AIOG-T-61 Bump version --- README.md | 2 +- README.rst | 2 +- aiogram/__init__.py | 4 ++-- aiogram/bot/api.py | 2 +- docs/source/index.rst | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 74de8a8d..dfef918f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) diff --git a/README.rst b/README.rst index 78dc071c..1cf2765d 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ AIOGramBot :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/aiogram/__init__.py b/aiogram/__init__.py index cb339a25..b077ae36 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = [ 'utils' ] -__version__ = '2.8' -__api_version__ = '4.8' +__version__ = '2.9' +__api_version__ = '4.9' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 3b341ec9..1d0c4f7b 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -153,7 +153,7 @@ class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api - List is updated to Bot API 4.8 + List is updated to Bot API 4.9 """ mode = HelperMode.lowerCamelCase diff --git a/docs/source/index.rst b/docs/source/index.rst index b18d386e..0ac6eccd 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,7 +22,7 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API From 027139f1b2b7dc553012a4fe2d39f16b0f354d0b Mon Sep 17 00:00:00 2001 From: George Imedashvili Date: Mon, 8 Jun 2020 18:21:15 +0100 Subject: [PATCH 05/39] fix get_full_command (#348) The reason is that .partition() doesn't have a default param as .split has, and default split param gives possibility to split not only by whitespaces, but also whitespace consequences (so the .strip() in get_args() not needed) and newlines. It's called "fix", because without it, commands like this: '''/command arg arg1''' are resulting with ('/command\narg\narg1', '', '') --- aiogram/types/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 9b9c0f82..b9452967 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -168,7 +168,7 @@ class Message(base.TelegramObject): :return: tuple of (command, args) """ if self.is_command(): - command, _, args = self.text.partition(' ') + command, args = self.text.split(maxsplit=1) return command, args def get_command(self, pure=False): @@ -192,7 +192,7 @@ class Message(base.TelegramObject): """ command = self.get_full_command() if command: - return command[1].strip() + return command[1] def parse_entities(self, as_html=True): """ From d5169a294f03c63e62589de2566725b8b7fcc08d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 8 Jun 2020 20:42:38 +0300 Subject: [PATCH 06/39] AIOG-T-23 Backport text_decorations from 3.0a --- aiogram/utils/text_decorations.py | 192 +++++++++++++++++++----------- 1 file changed, 123 insertions(+), 69 deletions(-) diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index ad52c9d7..77dc0ff3 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -1,33 +1,23 @@ from __future__ import annotations + import html import re -import struct -from dataclasses import dataclass -from typing import TYPE_CHECKING, AnyStr, Callable, Generator, Iterable, List, Optional +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Generator, List, Optional, Pattern, cast -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from aiogram.types import MessageEntity __all__ = ( "TextDecoration", + "HtmlDecoration", + "MarkdownDecoration", "html_decoration", "markdown_decoration", - "add_surrogate", - "remove_surrogate", ) -@dataclass -class TextDecoration: - link: str - bold: str - italic: str - code: str - pre: str - underline: str - strikethrough: str - quote: Callable[[AnyStr], AnyStr] - +class TextDecoration(ABC): def apply_entity(self, entity: MessageEntity, text: str) -> str: """ Apply single entity to text @@ -36,24 +26,28 @@ class TextDecoration: :param text: :return: """ - if entity.type in ( - "bold", - "italic", - "code", - "pre", - "underline", - "strikethrough", - ): - return getattr(self, entity.type).format(value=text) - elif entity.type == "text_mention": - return self.link.format(value=text, link=f"tg://user?id={entity.user.id}") - elif entity.type == "text_link": - return self.link.format(value=text, link=entity.url) - elif entity.type == "url": + if entity.type in {"bot_command", "url", "mention", "phone_number"}: + # This entities should not be changed return text + if entity.type in {"bold", "italic", "code", "underline", "strikethrough"}: + return cast(str, getattr(self, entity.type)(value=text)) + if entity.type == "pre": + return ( + self.pre_language(value=text, language=entity.language) + if entity.language + else self.pre(value=text) + ) + if entity.type == "text_mention": + from aiogram.types import User + + user = cast(User, entity.user) + return self.link(value=text, link=f"tg://user?id={user.id}") + if entity.type == "text_link": + return self.link(value=text, link=cast(str, entity.url)) + return self.quote(text) - def unparse(self, text, entities: Optional[List[MessageEntity]] = None) -> str: + def unparse(self, text: str, entities: Optional[List[MessageEntity]] = None) -> str: """ Unparse message entities @@ -61,22 +55,22 @@ class TextDecoration: :param entities: Array of MessageEntities :return: """ - text = add_surrogate(text) result = "".join( self._unparse_entities( text, sorted(entities, key=lambda item: item.offset) if entities else [] ) ) - return remove_surrogate(result) + return result def _unparse_entities( self, text: str, - entities: Iterable[MessageEntity], + entities: List[MessageEntity], offset: Optional[int] = None, length: Optional[int] = None, ) -> Generator[str, None, None]: - offset = offset or 0 + if offset is None: + offset = 0 length = length or len(text) for index, entity in enumerate(entities): @@ -88,7 +82,7 @@ class TextDecoration: offset = entity.offset + entity.length sub_entities = list( - filter(lambda e: e.offset < offset, entities[index + 1 :]) + filter(lambda e: e.offset < (offset or 0), entities[index + 1 :]) ) yield self.apply_entity( entity, @@ -102,42 +96,102 @@ class TextDecoration: if offset < length: yield self.quote(text[offset:length]) + @abstractmethod + def link(self, value: str, link: str) -> str: # pragma: no cover + pass -html_decoration = TextDecoration( - link='{value}', - bold="{value}", - italic="{value}", - code="{value}", - pre="
{value}
", - underline="{value}", - strikethrough="{value}", - quote=html.escape, -) + @abstractmethod + def bold(self, value: str) -> str: # pragma: no cover + pass -MARKDOWN_QUOTE_PATTERN = re.compile(r"([_*\[\]()~`>#+\-=|{}.!])") + @abstractmethod + def italic(self, value: str) -> str: # pragma: no cover + pass -markdown_decoration = TextDecoration( - link="[{value}]({link})", - bold="*{value}*", - italic="_{value}_\r", - code="`{value}`", - pre="```{value}```", - underline="__{value}__", - strikethrough="~{value}~", - quote=lambda text: re.sub( - pattern=MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=text - ), -) + @abstractmethod + def code(self, value: str) -> str: # pragma: no cover + pass + + @abstractmethod + def pre(self, value: str) -> str: # pragma: no cover + pass + + @abstractmethod + def pre_language(self, value: str, language: str) -> str: # pragma: no cover + pass + + @abstractmethod + def underline(self, value: str) -> str: # pragma: no cover + pass + + @abstractmethod + def strikethrough(self, value: str) -> str: # pragma: no cover + pass + + @abstractmethod + def quote(self, value: str) -> str: # pragma: no cover + pass -def add_surrogate(text: str) -> str: - return "".join( - "".join(chr(d) for d in struct.unpack(" str: + return f'{value}' + + def bold(self, value: str) -> str: + return f"{value}" + + def italic(self, value: str) -> str: + return f"{value}" + + def code(self, value: str) -> str: + return f"{value}" + + def pre(self, value: str) -> str: + return f"
{value}
" + + def pre_language(self, value: str, language: str) -> str: + return f'
{value}
' + + def underline(self, value: str) -> str: + return f"{value}" + + def strikethrough(self, value: str) -> str: + return f"{value}" + + def quote(self, value: str) -> str: + return html.escape(value) -def remove_surrogate(text: str) -> str: - return text.encode("utf-16", "surrogatepass").decode("utf-16") +class MarkdownDecoration(TextDecoration): + MARKDOWN_QUOTE_PATTERN: Pattern[str] = re.compile(r"([_*\[\]()~`>#+\-|{}.!])") + + def link(self, value: str, link: str) -> str: + return f"[{value}]({link})" + + def bold(self, value: str) -> str: + return f"*{value}*" + + def italic(self, value: str) -> str: + return f"_{value}_\r" + + def code(self, value: str) -> str: + return f"`{value}`" + + def pre(self, value: str) -> str: + return f"```{value}```" + + def pre_language(self, value: str, language: str) -> str: + return f"```{language}\n{value}\n```" + + def underline(self, value: str) -> str: + return f"__{value}__" + + def strikethrough(self, value: str) -> str: + return f"~{value}~" + + def quote(self, value: str) -> str: + return re.sub(pattern=self.MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=value) + + +html_decoration = HtmlDecoration() +markdown_decoration = MarkdownDecoration() From 557147ad8d39ec6d90f36a840c09b458a063e48f Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 9 Jun 2020 16:55:13 +0300 Subject: [PATCH 07/39] fix: markdown helper methods work correctly (#353) * fix: methods in markdown helper work now * chore: add return type annotations --- aiogram/utils/markdown.py | 56 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/aiogram/utils/markdown.py b/aiogram/utils/markdown.py index b56e14b1..da27bc39 100644 --- a/aiogram/utils/markdown.py +++ b/aiogram/utils/markdown.py @@ -18,7 +18,7 @@ HTML_QUOTES_MAP = {"<": "<", ">": ">", "&": "&", '"': """} _HQS = HTML_QUOTES_MAP.keys() # HQS for HTML QUOTES SYMBOLS -def quote_html(*content, sep=" "): +def quote_html(*content, sep=" ") -> str: """ Quote HTML symbols @@ -33,7 +33,7 @@ def quote_html(*content, sep=" "): return html_decoration.quote(_join(*content, sep=sep)) -def escape_md(*content, sep=" "): +def escape_md(*content, sep=" ") -> str: """ Escape markdown text @@ -61,7 +61,7 @@ def text(*content, sep=" "): return _join(*content, sep=sep) -def bold(*content, sep=" "): +def bold(*content, sep=" ") -> str: """ Make bold text (Markdown) @@ -69,12 +69,12 @@ def bold(*content, sep=" "): :param sep: :return: """ - return markdown_decoration.bold.format( + return markdown_decoration.bold( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hbold(*content, sep=" "): +def hbold(*content, sep=" ") -> str: """ Make bold text (HTML) @@ -82,12 +82,12 @@ def hbold(*content, sep=" "): :param sep: :return: """ - return html_decoration.bold.format( + return html_decoration.bold( value=html_decoration.quote(_join(*content, sep=sep)) ) -def italic(*content, sep=" "): +def italic(*content, sep=" ") -> str: """ Make italic text (Markdown) @@ -95,12 +95,12 @@ def italic(*content, sep=" "): :param sep: :return: """ - return markdown_decoration.italic.format( + return markdown_decoration.italic( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hitalic(*content, sep=" "): +def hitalic(*content, sep=" ") -> str: """ Make italic text (HTML) @@ -108,12 +108,12 @@ def hitalic(*content, sep=" "): :param sep: :return: """ - return html_decoration.italic.format( + return html_decoration.italic( value=html_decoration.quote(_join(*content, sep=sep)) ) -def code(*content, sep=" "): +def code(*content, sep=" ") -> str: """ Make mono-width text (Markdown) @@ -121,12 +121,12 @@ def code(*content, sep=" "): :param sep: :return: """ - return markdown_decoration.code.format( + return markdown_decoration.code( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hcode(*content, sep=" "): +def hcode(*content, sep=" ") -> str: """ Make mono-width text (HTML) @@ -134,12 +134,12 @@ def hcode(*content, sep=" "): :param sep: :return: """ - return html_decoration.code.format( + return html_decoration.code( value=html_decoration.quote(_join(*content, sep=sep)) ) -def pre(*content, sep="\n"): +def pre(*content, sep="\n") -> str: """ Make mono-width text block (Markdown) @@ -147,12 +147,12 @@ def pre(*content, sep="\n"): :param sep: :return: """ - return markdown_decoration.pre.format( + return markdown_decoration.pre( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hpre(*content, sep="\n"): +def hpre(*content, sep="\n") -> str: """ Make mono-width text block (HTML) @@ -160,12 +160,12 @@ def hpre(*content, sep="\n"): :param sep: :return: """ - return html_decoration.pre.format( + return html_decoration.pre( value=html_decoration.quote(_join(*content, sep=sep)) ) -def underline(*content, sep=" "): +def underline(*content, sep=" ") -> str: """ Make underlined text (Markdown) @@ -173,12 +173,12 @@ def underline(*content, sep=" "): :param sep: :return: """ - return markdown_decoration.underline.format( + return markdown_decoration.underline( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hunderline(*content, sep=" "): +def hunderline(*content, sep=" ") -> str: """ Make underlined text (HTML) @@ -186,12 +186,12 @@ def hunderline(*content, sep=" "): :param sep: :return: """ - return html_decoration.underline.format( + return html_decoration.underline( value=html_decoration.quote(_join(*content, sep=sep)) ) -def strikethrough(*content, sep=" "): +def strikethrough(*content, sep=" ") -> str: """ Make strikethrough text (Markdown) @@ -199,12 +199,12 @@ def strikethrough(*content, sep=" "): :param sep: :return: """ - return markdown_decoration.strikethrough.format( + return markdown_decoration.strikethrough( value=markdown_decoration.quote(_join(*content, sep=sep)) ) -def hstrikethrough(*content, sep=" "): +def hstrikethrough(*content, sep=" ") -> str: """ Make strikethrough text (HTML) @@ -212,7 +212,7 @@ def hstrikethrough(*content, sep=" "): :param sep: :return: """ - return html_decoration.strikethrough.format( + return html_decoration.strikethrough( value=html_decoration.quote(_join(*content, sep=sep)) ) @@ -225,7 +225,7 @@ def link(title: str, url: str) -> str: :param url: :return: """ - return markdown_decoration.link.format(value=markdown_decoration.quote(title), link=url) + return markdown_decoration.link(value=markdown_decoration.quote(title), link=url) def hlink(title: str, url: str) -> str: @@ -236,7 +236,7 @@ def hlink(title: str, url: str) -> str: :param url: :return: """ - return html_decoration.link.format(value=html_decoration.quote(title), link=url) + return html_decoration.link(value=html_decoration.quote(title), link=url) def hide_link(url: str) -> str: From a8dfe86358837e909b1e192d7ba6c0ef57541a65 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Jun 2020 23:07:55 +0300 Subject: [PATCH 08/39] feat: ForwardedMessage filter (#355) * feat: ForwardedMessage filter * fix: add tests * fix: attr name --- aiogram/dispatcher/dispatcher.py | 8 +++++- aiogram/dispatcher/filters/__init__.py | 3 ++- aiogram/dispatcher/filters/builtin.py | 10 +++++++ docs/source/dispatcher/filters.rst | 8 ++++++ .../test_filters/test_builtin.py | 27 ++++++++++++++++++- 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index b485fa49..164d6aad 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -10,7 +10,7 @@ from aiohttp.helpers import sentinel from aiogram.utils.deprecated import renamed_argument from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \ - RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter + RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter from .filters.builtin import IsSenderContact from .handler import Handler from .middlewares import MiddlewareManager @@ -160,6 +160,12 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.channel_post_handlers, self.edited_channel_post_handlers, ]) + filters_factory.bind(ForwardedMessageFilter, event_handlers=[ + self.message_handlers, + self.edited_channel_post_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers + ]) def __del__(self): self.stop_polling() diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 6de3cc7a..edd1959a 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,6 +1,6 @@ from .builtin import Command, CommandHelp, CommandPrivacy, CommandSettings, CommandStart, ContentTypeFilter, \ ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, \ - Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact + Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact, ForwardedMessageFilter from .factory import FiltersFactory from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \ check_filters, get_filter_spec, get_filters_spec @@ -32,4 +32,5 @@ __all__ = [ 'get_filters_spec', 'execute_filter', 'check_filters', + 'ForwardedMessageFilter', ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 5fe01dde..c59d9b0d 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -681,3 +681,13 @@ class IsReplyFilter(BoundFilter): return {'reply': msg.reply_to_message} elif not msg.reply_to_message and not self.is_reply: return True + + +class ForwardedMessageFilter(BoundFilter): + key = 'is_forwarded' + + def __init__(self, is_forwarded: bool): + self.is_forwarded = is_forwarded + + async def check(self, message: Message): + return bool(getattr(message, "forward_date")) is self.is_forwarded diff --git a/docs/source/dispatcher/filters.rst b/docs/source/dispatcher/filters.rst index af06b73e..3681dfcb 100644 --- a/docs/source/dispatcher/filters.rst +++ b/docs/source/dispatcher/filters.rst @@ -141,6 +141,14 @@ IsReplyFilter :show-inheritance: +ForwardedMessageFilter +------------- + +.. autoclass:: aiogram.dispatcher.filters.filters.ForwardedMessageFilter + :members: + :show-inheritance: + + Making own filters (Custom filters) =================================== diff --git a/tests/test_dispatcher/test_filters/test_builtin.py b/tests/test_dispatcher/test_filters/test_builtin.py index a26fc139..4cfce465 100644 --- a/tests/test_dispatcher/test_filters/test_builtin.py +++ b/tests/test_dispatcher/test_filters/test_builtin.py @@ -1,12 +1,15 @@ from typing import Set +from datetime import datetime import pytest from aiogram.dispatcher.filters.builtin import ( Text, extract_chat_ids, - ChatIDArgumentType, + ChatIDArgumentType, ForwardedMessageFilter, ) +from aiogram.types import Message +from tests.types.dataset import MESSAGE class TestText: @@ -69,3 +72,25 @@ class TestText: ) def test_extract_chat_ids(chat_id: ChatIDArgumentType, expected: Set[int]): assert extract_chat_ids(chat_id) == expected + + +class TestForwardedMessageFilter: + async def test_filter_forwarded_messages(self): + filter = ForwardedMessageFilter(is_forwarded=True) + + forwarded_message = Message(forward_date=round(datetime(2020, 5, 21, 5, 1).timestamp()), **MESSAGE) + + not_forwarded_message = Message(**MESSAGE) + + assert await filter.check(forwarded_message) + assert not await filter.check(not_forwarded_message) + + async def test_filter_not_forwarded_messages(self): + filter = ForwardedMessageFilter(is_forwarded=False) + + forwarded_message = Message(forward_date=round(datetime(2020, 5, 21, 5, 1).timestamp()), **MESSAGE) + + not_forwarded_message = Message(**MESSAGE) + + assert await filter.check(not_forwarded_message) + assert not await filter.check(forwarded_message) From 1389ca587401fd46961397def659f72a4dc6bffd Mon Sep 17 00:00:00 2001 From: Denis Belavin <41421345+LuckyDenis@users.noreply.github.com> Date: Wed, 10 Jun 2020 23:08:44 +0300 Subject: [PATCH 09/39] #320 - Fix: Class InputMediaAudio contains some fields from other class. (#354) Co-authored-by: Belavin Denis --- aiogram/types/input_media.py | 7 ++---- tests/types/test_input_media.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tests/types/test_input_media.py diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index 952e7a55..d42fac99 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -137,8 +137,6 @@ class InputMediaAudio(InputMedia): https://core.telegram.org/bots/api#inputmediaanimation """ - width: base.Integer = fields.Field() - height: base.Integer = fields.Field() duration: base.Integer = fields.Field() performer: base.String = fields.Field() title: base.String = fields.Field() @@ -146,13 +144,12 @@ class InputMediaAudio(InputMedia): def __init__(self, media: base.InputFile, thumb: typing.Union[base.InputFile, base.String] = None, caption: base.String = None, - width: base.Integer = None, height: base.Integer = None, duration: base.Integer = None, performer: base.String = None, title: base.String = None, parse_mode: base.String = None, **kwargs): - super(InputMediaAudio, self).__init__(type='audio', media=media, thumb=thumb, caption=caption, - width=width, height=height, duration=duration, + super(InputMediaAudio, self).__init__(type='audio', media=media, thumb=thumb, + caption=caption, duration=duration, performer=performer, title=title, parse_mode=parse_mode, conf=kwargs) diff --git a/tests/types/test_input_media.py b/tests/types/test_input_media.py new file mode 100644 index 00000000..953197c9 --- /dev/null +++ b/tests/types/test_input_media.py @@ -0,0 +1,42 @@ +from aiogram import types +from .dataset import AUDIO, ANIMATION, \ + DOCUMENT, PHOTO, VIDEO + + +WIDTH = 'width' +HEIGHT = 'height' + +input_media_audio = types.InputMediaAudio( + types.Audio(**AUDIO)) +input_media_animation = types.InputMediaAnimation( + types.Animation(**ANIMATION)) +input_media_document = types.InputMediaDocument( + types.Document(**DOCUMENT)) +input_media_video = types.InputMediaVideo( + types.Video(**VIDEO)) +input_media_photo = types.InputMediaPhoto( + types.PhotoSize(**PHOTO)) + + +def test_field_width(): + """ + https://core.telegram.org/bots/api#inputmedia + """ + assert not hasattr(input_media_audio, WIDTH) + assert not hasattr(input_media_document, WIDTH) + assert not hasattr(input_media_photo, WIDTH) + + assert hasattr(input_media_animation, WIDTH) + assert hasattr(input_media_video, WIDTH) + + +def test_field_height(): + """ + https://core.telegram.org/bots/api#inputmedia + """ + assert not hasattr(input_media_audio, HEIGHT) + assert not hasattr(input_media_document, HEIGHT) + assert not hasattr(input_media_photo, HEIGHT) + + assert hasattr(input_media_animation, HEIGHT) + assert hasattr(input_media_video, HEIGHT) From 8d30c1dc1b9a494178835ec09e389444cd6fcbbd Mon Sep 17 00:00:00 2001 From: Egor Dementyev Date: Sat, 13 Jun 2020 19:18:30 +0300 Subject: [PATCH 10/39] Fix message.get_full_command() (#352) * Fix message.get_full_command() * Fix message.get_full_command() by JrooTJunior Co-authored-by: Alex Root Junior * fix typos Co-authored-by: Alex Root Junior --- aiogram/types/message.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index b9452967..2ea65bfb 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -168,7 +168,8 @@ class Message(base.TelegramObject): :return: tuple of (command, args) """ if self.is_command(): - command, args = self.text.split(maxsplit=1) + command, *args = self.text.split(maxsplit=1) + args = args[0] if args else None return command, args def get_command(self, pure=False): From 21b4b64db1ba5ed8465a32279cd2efd4165b2b44 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 13 Jun 2020 21:30:24 +0300 Subject: [PATCH 11/39] Bump version --- aiogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index b077ae36..0dba109a 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = [ 'utils' ] -__version__ = '2.9' +__version__ = '2.9.1' __api_version__ = '4.9' From 09f3c35aec02a20669ead5144b21536fee4cfb99 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 13 Jun 2020 21:53:31 +0300 Subject: [PATCH 12/39] Hotfix get_full_command --- aiogram/types/message.py | 1327 +++++++++++++++++++++++--------------- 1 file changed, 797 insertions(+), 530 deletions(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 2ea65bfb..c56a143a 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -4,8 +4,10 @@ import datetime import functools import typing -from . import base -from . import fields +from ..utils import helper +from ..utils import markdown as md +from ..utils.text_decorations import html_decoration, markdown_decoration +from . import base, fields from .animation import Animation from .audio import Audio from .chat import Chat, ChatType @@ -15,14 +17,14 @@ from .document import Document from .force_reply import ForceReply from .game import Game from .inline_keyboard import InlineKeyboardMarkup -from .input_media import MediaGroup, InputMedia +from .input_media import InputMedia, MediaGroup from .invoice import Invoice from .location import Location from .message_entity import MessageEntity from .passport_data import PassportData from .photo_size import PhotoSize from .poll import Poll -from .reply_keyboard import ReplyKeyboardRemove, ReplyKeyboardMarkup +from .reply_keyboard import ReplyKeyboardMarkup, ReplyKeyboardRemove from .sticker import Sticker from .successful_payment import SuccessfulPayment from .user import User @@ -30,9 +32,6 @@ from .venue import Venue from .video import Video from .video_note import VideoNote from .voice import Voice -from ..utils import helper -from ..utils import markdown as md -from ..utils.text_decorations import html_decoration, markdown_decoration class Message(base.TelegramObject): @@ -41,8 +40,9 @@ class Message(base.TelegramObject): https://core.telegram.org/bots/api#message """ + message_id: base.Integer = fields.Field() - from_user: User = fields.Field(alias='from', base=User) + from_user: User = fields.Field(alias="from", base=User) date: datetime.datetime = fields.DateTimeField() chat: Chat = fields.Field(base=Chat) forward_from: User = fields.Field(base=User) @@ -50,7 +50,7 @@ class Message(base.TelegramObject): forward_from_message_id: base.Integer = fields.Field() forward_signature: base.String = fields.Field() forward_date: datetime.datetime = fields.DateTimeField() - reply_to_message: Message = fields.Field(base='Message') + reply_to_message: Message = fields.Field(base="Message") via_bot: User = fields.Field(base=User) edit_date: datetime.datetime = fields.DateTimeField() media_group_id: base.String = fields.Field() @@ -84,7 +84,7 @@ class Message(base.TelegramObject): channel_chat_created: base.Boolean = fields.Field() migrate_to_chat_id: base.Integer = fields.Field() migrate_from_chat_id: base.Integer = fields.Field() - pinned_message: Message = fields.Field(base='Message') + pinned_message: Message = fields.Field(base="Message") invoice: Invoice = fields.Field(base=Invoice) successful_payment: SuccessfulPayment = fields.Field(base=SuccessfulPayment) connected_website: base.String = fields.Field() @@ -159,7 +159,7 @@ class Message(base.TelegramObject): :return: bool """ - return self.text and self.text.startswith('/') + return self.text and self.text.startswith("/") def get_full_command(self): """ @@ -169,7 +169,7 @@ class Message(base.TelegramObject): """ if self.is_command(): command, *args = self.text.split(maxsplit=1) - args = args[0] if args else None + args = args[-1] if args else "" return command, args def get_command(self, pure=False): @@ -182,7 +182,7 @@ class Message(base.TelegramObject): if command: command = command[0] if pure: - command, _, _ = command[1:].partition('@') + command, _, _ = command[1:].partition("@") return command def get_args(self): @@ -237,16 +237,16 @@ class Message(base.TelegramObject): :return: str """ if ChatType.is_private(self.chat): - raise TypeError('Invalid chat type!') + raise TypeError("Invalid chat type!") - url = 'https://t.me/' + url = "https://t.me/" if self.chat.username: # Generates public link - url += f'{self.chat.username}/' + url += f"{self.chat.username}/" else: # Generates private link available for chat members - url += f'c/{self.chat.shifted_id}/' - url += f'{self.message_id}' + url += f"c/{self.chat.shifted_id}/" + url += f"{self.message_id}" return url @@ -269,15 +269,21 @@ class Message(base.TelegramObject): return md.hlink(text, url) return md.link(text, url) - async def answer(self, text: base.String, - parse_mode: typing.Union[base.String, None] = None, - disable_web_page_preview: typing.Union[base.Boolean, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer( + self, + text: base.String, + parse_mode: typing.Union[base.String, None] = None, + disable_web_page_preview: typing.Union[base.Boolean, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Answer to this message @@ -299,23 +305,31 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_message(chat_id=self.chat.id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_message( + chat_id=self.chat.id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_photo(self, photo: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_photo( + self, + photo: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send photos. @@ -339,26 +353,34 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_photo(chat_id=self.chat.id, - photo=photo, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_photo( + chat_id=self.chat.id, + photo=photo, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_audio(self, audio: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - performer: typing.Union[base.String, None] = None, - title: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_audio( + self, + audio: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + performer: typing.Union[base.String, None] = None, + title: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display them in the music player. Your audio must be in the .mp3 format. @@ -390,30 +412,38 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_audio(chat_id=self.chat.id, - audio=audio, - caption=caption, - parse_mode=parse_mode, - duration=duration, - performer=performer, - title=title, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_audio( + chat_id=self.chat.id, + audio=audio, + caption=caption, + parse_mode=parse_mode, + duration=duration, + performer=performer, + title=title, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_animation(self, animation: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_animation( + self, + animation: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + width: typing.Union[base.Integer, None] = None, + height: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -450,27 +480,35 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_animation(self.chat.id, - animation=animation, - duration=duration, - width=width, - height=height, - thumb=thumb, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_animation( + self.chat.id, + animation=animation, + duration=duration, + width=width, + height=height, + thumb=thumb, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_document(self, document: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_document( + self, + document: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send general files. @@ -495,26 +533,34 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_document(chat_id=self.chat.id, - document=document, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_document( + chat_id=self.chat.id, + document=document, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_video(self, video: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_video( + self, + video: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + width: typing.Union[base.Integer, None] = None, + height: typing.Union[base.Integer, None] = None, + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -544,27 +590,35 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_video(chat_id=self.chat.id, - video=video, - duration=duration, - width=width, - height=height, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_video( + chat_id=self.chat.id, + video=video, + duration=duration, + width=width, + height=height, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_voice(self, voice: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_voice( + self, + voice: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. @@ -593,24 +647,32 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_voice(chat_id=self.chat.id, - voice=voice, - caption=caption, - parse_mode=parse_mode, - duration=duration, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_voice( + chat_id=self.chat.id, + voice=voice, + caption=caption, + parse_mode=parse_mode, + duration=duration, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_video_note(self, video_note: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - length: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_video_note( + self, + video_note: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + length: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. Use this method to send video messages. @@ -633,17 +695,22 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_video_note(chat_id=self.chat.id, - video_note=video_note, - duration=duration, - length=length, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_video_note( + chat_id=self.chat.id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_media_group(self, media: typing.Union[MediaGroup, typing.List], - disable_notification: typing.Union[base.Boolean, None] = None, - reply: base.Boolean = False) -> typing.List[Message]: + async def answer_media_group( + self, + media: typing.Union[MediaGroup, typing.List], + disable_notification: typing.Union[base.Boolean, None] = None, + reply: base.Boolean = False, + ) -> typing.List[Message]: """ Use this method to send a group of photos or videos as an album. @@ -657,20 +724,28 @@ class Message(base.TelegramObject): :return: On success, an array of the sent Messages is returned. :rtype: typing.List[types.Message] """ - return await self.bot.send_media_group(self.chat.id, - media=media, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None) + return await self.bot.send_media_group( + self.chat.id, + media=media, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + ) - async def answer_location(self, - latitude: base.Float, longitude: base.Float, - live_period: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_location( + self, + latitude: base.Float, + longitude: base.Float, + live_period: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send point on the map. @@ -692,24 +767,33 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_location(chat_id=self.chat.id, - latitude=latitude, - longitude=longitude, - live_period=live_period, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_location( + chat_id=self.chat.id, + latitude=latitude, + longitude=longitude, + live_period=live_period, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_venue(self, - latitude: base.Float, longitude: base.Float, - title: base.String, address: base.String, - foursquare_id: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_venue( + self, + latitude: base.Float, + longitude: base.Float, + title: base.String, + address: base.String, + foursquare_id: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send information about a venue. @@ -735,24 +819,33 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_venue(chat_id=self.chat.id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_venue( + chat_id=self.chat.id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_contact(self, phone_number: base.String, - first_name: base.String, last_name: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_contact( + self, + phone_number: base.String, + first_name: base.String, + last_name: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send phone contacts. @@ -774,20 +867,29 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_contact(chat_id=self.chat.id, - phone_number=phone_number, - first_name=first_name, last_name=last_name, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_contact( + chat_id=self.chat.id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_sticker(self, sticker: typing.Union[base.InputFile, base.String], - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_sticker( + self, + sticker: typing.Union[base.InputFile, base.String], + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send .webp stickers. @@ -805,19 +907,27 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_sticker(chat_id=self.chat.id, - sticker=sticker, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_sticker( + chat_id=self.chat.id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def answer_dice(self, emoji: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = False) -> Message: + async def answer_dice( + self, + emoji: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: """ Use this method to send a dice, which will have a random value from 1 to 6. On success, the sent Message is returned. @@ -838,21 +948,29 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_dice(chat_id=self.chat.id, - disable_notification=disable_notification, - emoji=emoji, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_dice( + chat_id=self.chat.id, + disable_notification=disable_notification, + emoji=emoji, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply(self, text: base.String, - parse_mode: typing.Union[base.String, None] = None, - disable_web_page_preview: typing.Union[base.Boolean, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply( + self, + text: base.String, + parse_mode: typing.Union[base.String, None] = None, + disable_web_page_preview: typing.Union[base.Boolean, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Reply to this message @@ -874,23 +992,31 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_message(chat_id=self.chat.id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_message( + chat_id=self.chat.id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_photo(self, photo: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_photo( + self, + photo: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send photos. @@ -914,26 +1040,34 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_photo(chat_id=self.chat.id, - photo=photo, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_photo( + chat_id=self.chat.id, + photo=photo, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_audio(self, audio: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - performer: typing.Union[base.String, None] = None, - title: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_audio( + self, + audio: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + performer: typing.Union[base.String, None] = None, + title: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display them in the music player. Your audio must be in the .mp3 format. @@ -965,30 +1099,38 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_audio(chat_id=self.chat.id, - audio=audio, - caption=caption, - parse_mode=parse_mode, - duration=duration, - performer=performer, - title=title, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_audio( + chat_id=self.chat.id, + audio=audio, + caption=caption, + parse_mode=parse_mode, + duration=duration, + performer=performer, + title=title, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_animation(self, animation: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_animation( + self, + animation: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + width: typing.Union[base.Integer, None] = None, + height: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -1025,27 +1167,35 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_animation(self.chat.id, - animation=animation, - duration=duration, - width=width, - height=height, - thumb=thumb, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_animation( + self.chat.id, + animation=animation, + duration=duration, + width=width, + height=height, + thumb=thumb, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_document(self, document: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_document( + self, + document: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send general files. @@ -1070,26 +1220,34 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_document(chat_id=self.chat.id, - document=document, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_document( + chat_id=self.chat.id, + document=document, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_video(self, video: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_video( + self, + video: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + width: typing.Union[base.Integer, None] = None, + height: typing.Union[base.Integer, None] = None, + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -1119,27 +1277,35 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_video(chat_id=self.chat.id, - video=video, - duration=duration, - width=width, - height=height, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_video( + chat_id=self.chat.id, + video=video, + duration=duration, + width=width, + height=height, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_voice(self, voice: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_voice( + self, + voice: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. @@ -1168,24 +1334,32 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_voice(chat_id=self.chat.id, - voice=voice, - caption=caption, - parse_mode=parse_mode, - duration=duration, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_voice( + chat_id=self.chat.id, + voice=voice, + caption=caption, + parse_mode=parse_mode, + duration=duration, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_video_note(self, video_note: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - length: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_video_note( + self, + video_note: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + length: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. Use this method to send video messages. @@ -1208,17 +1382,22 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_video_note(chat_id=self.chat.id, - video_note=video_note, - duration=duration, - length=length, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_video_note( + chat_id=self.chat.id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_media_group(self, media: typing.Union[MediaGroup, typing.List], - disable_notification: typing.Union[base.Boolean, None] = None, - reply: base.Boolean = True) -> typing.List[Message]: + async def reply_media_group( + self, + media: typing.Union[MediaGroup, typing.List], + disable_notification: typing.Union[base.Boolean, None] = None, + reply: base.Boolean = True, + ) -> typing.List[Message]: """ Use this method to send a group of photos or videos as an album. @@ -1232,20 +1411,28 @@ class Message(base.TelegramObject): :return: On success, an array of the sent Messages is returned. :rtype: typing.List[types.Message] """ - return await self.bot.send_media_group(self.chat.id, - media=media, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None) + return await self.bot.send_media_group( + self.chat.id, + media=media, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + ) - async def reply_location(self, - latitude: base.Float, longitude: base.Float, - live_period: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_location( + self, + latitude: base.Float, + longitude: base.Float, + live_period: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send point on the map. @@ -1267,24 +1454,33 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_location(chat_id=self.chat.id, - latitude=latitude, - longitude=longitude, - live_period=live_period, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_location( + chat_id=self.chat.id, + latitude=latitude, + longitude=longitude, + live_period=live_period, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_venue(self, - latitude: base.Float, longitude: base.Float, - title: base.String, address: base.String, - foursquare_id: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_venue( + self, + latitude: base.Float, + longitude: base.Float, + title: base.String, + address: base.String, + foursquare_id: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send information about a venue. @@ -1310,24 +1506,33 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_venue(chat_id=self.chat.id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_venue( + chat_id=self.chat.id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_contact(self, phone_number: base.String, - first_name: base.String, last_name: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_contact( + self, + phone_number: base.String, + first_name: base.String, + last_name: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send phone contacts. @@ -1349,20 +1554,29 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_contact(chat_id=self.chat.id, - phone_number=phone_number, - first_name=first_name, last_name=last_name, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_contact( + chat_id=self.chat.id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_sticker(self, sticker: typing.Union[base.InputFile, base.String], - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_sticker( + self, + sticker: typing.Union[base.InputFile, base.String], + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send .webp stickers. @@ -1380,19 +1594,27 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_sticker(chat_id=self.chat.id, - sticker=sticker, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_sticker( + chat_id=self.chat.id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def reply_dice(self, emoji: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: + async def reply_dice( + self, + emoji: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: """ Use this method to send a dice, which will have a random value from 1 to 6. On success, the sent Message is returned. @@ -1413,13 +1635,18 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned. :rtype: :obj:`types.Message` """ - return await self.bot.send_dice(chat_id=self.chat.id, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) + return await self.bot.send_dice( + chat_id=self.chat.id, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) - async def forward(self, chat_id: typing.Union[base.Integer, base.String], - disable_notification: typing.Union[base.Boolean, None] = None) -> Message: + async def forward( + self, + chat_id: typing.Union[base.Integer, base.String], + disable_notification: typing.Union[base.Boolean, None] = None, + ) -> Message: """ Forward this message @@ -1432,13 +1659,17 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.forward_message(chat_id, self.chat.id, self.message_id, disable_notification) + return await self.bot.forward_message( + chat_id, self.chat.id, self.message_id, disable_notification + ) - async def edit_text(self, text: base.String, - parse_mode: typing.Union[base.String, None] = None, - disable_web_page_preview: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def edit_text( + self, + text: base.String, + parse_mode: typing.Union[base.String, None] = None, + disable_web_page_preview: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, None] = None, + ) -> typing.Union[Message, base.Boolean]: """ Use this method to edit text and game messages sent by the bot or via the bot (for inline bots). @@ -1457,16 +1688,21 @@ class Message(base.TelegramObject): the edited Message is returned, otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_text(text=text, - chat_id=self.chat.id, message_id=self.message_id, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - reply_markup=reply_markup) + return await self.bot.edit_message_text( + text=text, + chat_id=self.chat.id, + message_id=self.message_id, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + reply_markup=reply_markup, + ) - async def edit_caption(self, caption: base.String, - parse_mode: typing.Union[base.String, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def edit_caption( + self, + caption: base.String, + parse_mode: typing.Union[base.String, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, None] = None, + ) -> typing.Union[Message, base.Boolean]: """ Use this method to edit captions of messages sent by the bot or via the bot (for inline bots). @@ -1483,12 +1719,19 @@ class Message(base.TelegramObject): otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_caption(chat_id=self.chat.id, message_id=self.message_id, caption=caption, - parse_mode=parse_mode, reply_markup=reply_markup) + return await self.bot.edit_message_caption( + chat_id=self.chat.id, + message_id=self.message_id, + caption=caption, + parse_mode=parse_mode, + reply_markup=reply_markup, + ) - async def edit_media(self, media: InputMedia, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def edit_media( + self, + media: InputMedia, + reply_markup: typing.Union[InlineKeyboardMarkup, None] = None, + ) -> typing.Union[Message, base.Boolean]: """ Use this method to edit audio, document, photo, or video messages. If a message is a part of a message album, then it can be edited only to a photo or a video. @@ -1509,12 +1752,16 @@ class Message(base.TelegramObject): otherwise True is returned :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_media(media=media, chat_id=self.chat.id, message_id=self.message_id, - reply_markup=reply_markup) + return await self.bot.edit_message_media( + media=media, + chat_id=self.chat.id, + message_id=self.message_id, + reply_markup=reply_markup, + ) - async def edit_reply_markup(self, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def edit_reply_markup( + self, reply_markup: typing.Union[InlineKeyboardMarkup, None] = None + ) -> typing.Union[Message, base.Boolean]: """ Use this method to edit only the reply markup of messages sent by the bot or via the bot (for inline bots). @@ -1526,8 +1773,9 @@ class Message(base.TelegramObject): otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_reply_markup(chat_id=self.chat.id, message_id=self.message_id, - reply_markup=reply_markup) + return await self.bot.edit_message_reply_markup( + chat_id=self.chat.id, message_id=self.message_id, reply_markup=reply_markup + ) async def delete_reply_markup(self) -> typing.Union[Message, base.Boolean]: """ @@ -1537,12 +1785,16 @@ class Message(base.TelegramObject): otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_reply_markup(chat_id=self.chat.id, message_id=self.message_id) + return await self.bot.edit_message_reply_markup( + chat_id=self.chat.id, message_id=self.message_id + ) - async def edit_live_location(self, latitude: base.Float, - longitude: base.Float, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def edit_live_location( + self, + latitude: base.Float, + longitude: base.Float, + reply_markup: typing.Union[InlineKeyboardMarkup, None] = None, + ) -> typing.Union[Message, base.Boolean]: """ Use this method to edit live location messages sent by the bot or via the bot (for inline bots). A location can be edited until its live_period expires or editing is explicitly disabled by a call @@ -1560,13 +1812,17 @@ class Message(base.TelegramObject): otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.edit_message_live_location(latitude=latitude, longitude=longitude, - chat_id=self.chat.id, message_id=self.message_id, - reply_markup=reply_markup) + return await self.bot.edit_message_live_location( + latitude=latitude, + longitude=longitude, + chat_id=self.chat.id, + message_id=self.message_id, + reply_markup=reply_markup, + ) - async def stop_live_location(self, - reply_markup: typing.Union[InlineKeyboardMarkup, - None] = None) -> typing.Union[Message, base.Boolean]: + async def stop_live_location( + self, reply_markup: typing.Union[InlineKeyboardMarkup, None] = None + ) -> typing.Union[Message, base.Boolean]: """ Use this method to stop updating a live location message sent by the bot or via the bot (for inline bots) before live_period expires. @@ -1579,8 +1835,9 @@ class Message(base.TelegramObject): otherwise True is returned. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - return await self.bot.stop_message_live_location(chat_id=self.chat.id, message_id=self.message_id, - reply_markup=reply_markup) + return await self.bot.stop_message_live_location( + chat_id=self.chat.id, message_id=self.message_id, reply_markup=reply_markup + ) async def delete(self) -> base.Boolean: """ @@ -1599,7 +1856,9 @@ class Message(base.TelegramObject): """ return await self.bot.delete_message(self.chat.id, self.message_id) - async def pin(self, disable_notification: typing.Union[base.Boolean, None] = None) -> base.Boolean: + async def pin( + self, disable_notification: typing.Union[base.Boolean, None] = None + ) -> base.Boolean: """ Use this method to pin a message in a supergroup. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. @@ -1615,11 +1874,13 @@ class Message(base.TelegramObject): return await self.chat.pin_message(self.message_id, disable_notification) async def send_copy( - self: Message, - chat_id: typing.Union[str, int], - disable_notification: typing.Optional[bool] = None, - reply_to_message_id: typing.Optional[int] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None, + self: Message, + chat_id: typing.Union[str, int], + disable_notification: typing.Optional[bool] = None, + reply_to_message_id: typing.Optional[int] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, ReplyKeyboardMarkup, None + ] = None, ) -> Message: """ Send copy of current message @@ -1648,7 +1909,7 @@ class Message(base.TelegramObject): title=self.audio.title, performer=self.audio.performer, duration=self.audio.duration, - **kwargs + **kwargs, ) elif self.animation: return await self.bot.send_animation( @@ -1683,7 +1944,7 @@ class Message(base.TelegramObject): first_name=self.contact.first_name, last_name=self.contact.last_name, vcard=self.contact.vcard, - **kwargs + **kwargs, ) elif self.venue: kwargs.pop("parse_mode") @@ -1694,17 +1955,21 @@ class Message(base.TelegramObject): address=self.venue.address, foursquare_id=self.venue.foursquare_id, foursquare_type=self.venue.foursquare_type, - **kwargs + **kwargs, ) elif self.location: kwargs.pop("parse_mode") return await self.bot.send_location( - latitude=self.location.latitude, longitude=self.location.longitude, **kwargs + latitude=self.location.latitude, + longitude=self.location.longitude, + **kwargs, ) elif self.poll: kwargs.pop("parse_mode") return await self.bot.send_poll( - question=self.poll.question, options=[option.text for option in self.poll.options], **kwargs + question=self.poll.question, + options=[option.text for option in self.poll.options], + **kwargs, ) else: raise TypeError("This type of message can't be copied.") @@ -1741,6 +2006,7 @@ class ContentType(helper.Helper): :key: UNKNOWN :key: ANY """ + mode = helper.HelperMode.snake_case TEXT = helper.Item() # text @@ -1804,6 +2070,7 @@ class ContentTypes(helper.Helper): :key: UNKNOWN :key: ANY """ + mode = helper.HelperMode.snake_case TEXT = helper.ListItem() # text From 4647b17c3cb2d2c8909d9753bfdaa8ec1d0edbbd Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 13 Jun 2020 21:54:25 +0300 Subject: [PATCH 13/39] Bump version --- aiogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 0dba109a..bebafcec 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = [ 'utils' ] -__version__ = '2.9.1' +__version__ = '2.9.2' __api_version__ = '4.9' From b004c8af199aa942a6c14fade64f267d6c7f7579 Mon Sep 17 00:00:00 2001 From: Yyonging Date: Sat, 27 Jun 2020 21:10:09 +0800 Subject: [PATCH 14/39] fix the #307 (#371) --- aiogram/types/fields.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aiogram/types/fields.py b/aiogram/types/fields.py index e0d5b892..022b9b72 100644 --- a/aiogram/types/fields.py +++ b/aiogram/types/fields.py @@ -1,5 +1,6 @@ import abc import datetime +import weakref __all__ = ('BaseField', 'Field', 'ListField', 'DateTimeField', 'TextField', 'ListOfLists') @@ -109,7 +110,9 @@ class Field(BaseField): and self.base_object is not None \ and not hasattr(value, 'base_object') \ and not hasattr(value, 'to_python'): - return self.base_object(conf={'parent': parent}, **value) + if not isinstance(parent, weakref.ReferenceType): + parent = weakref.ref(parent) + return self.base_object(conf={'parent':parent}, **value) return value From c9cbde4595448c3bed4e2232197e2d8df088759b Mon Sep 17 00:00:00 2001 From: Abstract-X <44748702+Abstract-X@users.noreply.github.com> Date: Sun, 28 Jun 2020 00:15:20 +1100 Subject: [PATCH 15/39] Add setting current context of types (#369) --- aiogram/dispatcher/dispatcher.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 164d6aad..a236df57 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -209,39 +209,50 @@ class Dispatcher(DataMixin, ContextInstanceMixin): try: if update.message: + types.Message.set_current(update.message) types.User.set_current(update.message.from_user) types.Chat.set_current(update.message.chat) return await self.message_handlers.notify(update.message) if update.edited_message: + types.Message.set_current(update.edited_message) types.User.set_current(update.edited_message.from_user) types.Chat.set_current(update.edited_message.chat) return await self.edited_message_handlers.notify(update.edited_message) if update.channel_post: + types.Message.set_current(update.channel_post) types.Chat.set_current(update.channel_post.chat) return await self.channel_post_handlers.notify(update.channel_post) if update.edited_channel_post: + types.Message.set_current(update.edited_channel_post) types.Chat.set_current(update.edited_channel_post.chat) return await self.edited_channel_post_handlers.notify(update.edited_channel_post) if update.inline_query: + types.InlineQuery.set_current(update.inline_query) types.User.set_current(update.inline_query.from_user) return await self.inline_query_handlers.notify(update.inline_query) if update.chosen_inline_result: + types.ChosenInlineResult.set_current(update.chosen_inline_result) types.User.set_current(update.chosen_inline_result.from_user) return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) if update.callback_query: + types.CallbackQuery.set_current(update.callback_query) if update.callback_query.message: types.Chat.set_current(update.callback_query.message.chat) types.User.set_current(update.callback_query.from_user) return await self.callback_query_handlers.notify(update.callback_query) if update.shipping_query: + types.ShippingQuery.set_current(update.shipping_query) types.User.set_current(update.shipping_query.from_user) return await self.shipping_query_handlers.notify(update.shipping_query) if update.pre_checkout_query: + types.PreCheckoutQuery.set_current(update.pre_checkout_query) types.User.set_current(update.pre_checkout_query.from_user) return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) if update.poll: + types.Poll.set_current(update.poll) return await self.poll_handlers.notify(update.poll) if update.poll_answer: + types.PollAnswer.set_current(update.poll_answer) types.User.set_current(update.poll_answer.user) return await self.poll_answer_handlers.notify(update.poll_answer) except Exception as e: From f9c367548fbeac1fc1a487abfa13fb59b25aefec Mon Sep 17 00:00:00 2001 From: unintended Date: Sat, 27 Jun 2020 16:17:38 +0300 Subject: [PATCH 16/39] Fix markdown escaping issues (#363) * #360 - Fix: escape '=' sign in markdown * fix more escaping issues * Rename test suite Co-authored-by: Alex Root Junior --- aiogram/utils/text_decorations.py | 2 +- tests/test_utils/test_markdown.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils/test_markdown.py diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 77dc0ff3..3d22f637 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -163,7 +163,7 @@ class HtmlDecoration(TextDecoration): 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})" diff --git a/tests/test_utils/test_markdown.py b/tests/test_utils/test_markdown.py new file mode 100644 index 00000000..02faea2a --- /dev/null +++ b/tests/test_utils/test_markdown.py @@ -0,0 +1,11 @@ +import pytest + +from aiogram.utils import markdown + + +class TestMarkdownEscape: + def test_equality_sign_is_escaped(self): + assert markdown.escape_md(r"e = mc2") == r"e \= mc2" + + def test_pre_escaped(self): + assert markdown.escape_md(r"hello\.") == r"hello\\\." From 3d39652e9ac42491470b55d4a7765256940ced7b Mon Sep 17 00:00:00 2001 From: Abstract-X <44748702+Abstract-X@users.noreply.github.com> Date: Tue, 30 Jun 2020 05:53:51 +1100 Subject: [PATCH 17/39] Add methods to register filters and middlewares (#370) * Add methods to register filters and middlewares * Fix docstring in filter unregistration method --- aiogram/dispatcher/dispatcher.py | 35 ++++++++++++++++++++++++++- aiogram/dispatcher/filters/factory.py | 2 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index a236df57..5dfc8cf9 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -10,7 +10,8 @@ from aiohttp.helpers import sentinel from aiogram.utils.deprecated import renamed_argument from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \ - RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter + RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter, \ + AbstractFilter from .filters.builtin import IsSenderContact from .handler import Handler from .middlewares import MiddlewareManager @@ -1232,3 +1233,35 @@ class Dispatcher(DataMixin, ContextInstanceMixin): return wrapped return decorator + + def bind_filter(self, callback: typing.Union[typing.Callable, AbstractFilter], + validator: typing.Optional[typing.Callable] = None, + event_handlers: typing.Optional[typing.List[Handler]] = None, + exclude_event_handlers: typing.Optional[typing.Iterable[Handler]] = None): + """ + Register filter + + :param callback: callable or subclass of :obj:`AbstractFilter` + :param validator: custom validator. + :param event_handlers: list of instances of :obj:`Handler` + :param exclude_event_handlers: list of excluded event handlers (:obj:`Handler`) + """ + self.filters_factory.bind(callback=callback, validator=validator, event_handlers=event_handlers, + exclude_event_handlers=exclude_event_handlers) + + def unbind_filter(self, callback: typing.Union[typing.Callable, AbstractFilter]): + """ + Unregister filter + + :param callback: callable of subclass of :obj:`AbstractFilter` + """ + self.filters_factory.unbind(callback=callback) + + def setup_middleware(self, middleware): + """ + Setup middleware + + :param middleware: + :return: + """ + self.middleware.setup(middleware) diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 13b188ff..564e7f89 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -30,7 +30,7 @@ class FiltersFactory: def unbind(self, callback: typing.Union[typing.Callable, AbstractFilter]): """ - Unregister callback + Unregister filter :param callback: callable of subclass of :obj:`AbstractFilter` """ From 3548d40eab438cd089e15a1a8f5163e9a41664ca Mon Sep 17 00:00:00 2001 From: ixearth <35573806+ixearth@users.noreply.github.com> Date: Mon, 29 Jun 2020 18:54:47 +0000 Subject: [PATCH 18/39] Add missing thumb parameter (#362) --- aiogram/bot/bot.py | 2 +- aiogram/types/message.py | 44 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 41f30af1..eb32d7f9 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -503,7 +503,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param height: Animation height :type height: :obj:`typing.Union[base.Integer, None]` :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. - A thumbnail‘s width and height should not exceed 90. + A thumbnail‘s width and height should not exceed 320. :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Animation caption (may also be used when resending animation by file_id), 0-1024 characters :type caption: :obj:`typing.Union[base.String, None]` diff --git a/aiogram/types/message.py b/aiogram/types/message.py index c56a143a..de53df6a 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -371,6 +371,7 @@ class Message(base.TelegramObject): duration: typing.Union[base.Integer, None] = None, performer: typing.Union[base.String, None] = None, title: typing.Union[base.String, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Union[base.Boolean, None] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -402,6 +403,9 @@ class Message(base.TelegramObject): :type performer: :obj:`typing.Union[base.String, None]` :param title: Track name :type title: :obj:`typing.Union[base.String, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, @@ -420,6 +424,7 @@ class Message(base.TelegramObject): duration=duration, performer=performer, title=title, + thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup, @@ -463,7 +468,7 @@ class Message(base.TelegramObject): :param height: Animation height :type height: :obj:`typing.Union[base.Integer, None]` :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. - A thumbnail‘s width and height should not exceed 90. + A thumbnail‘s width and height should not exceed 320. :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Animation caption (may also be used when resending animation by file_id), 0-1024 characters :type caption: :obj:`typing.Union[base.String, None]` @@ -497,6 +502,7 @@ class Message(base.TelegramObject): async def answer_document( self, document: typing.Union[base.InputFile, base.String], + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, caption: typing.Union[base.String, None] = None, parse_mode: typing.Union[base.String, None] = None, disable_notification: typing.Union[base.Boolean, None] = None, @@ -518,6 +524,9 @@ class Message(base.TelegramObject): :param document: File to send. :type document: :obj:`typing.Union[base.InputFile, base.String]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Document caption (may also be used when resending documents by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -535,6 +544,7 @@ class Message(base.TelegramObject): """ return await self.bot.send_document( chat_id=self.chat.id, + thumb=thumb, document=document, caption=caption, parse_mode=parse_mode, @@ -549,6 +559,7 @@ class Message(base.TelegramObject): duration: typing.Union[base.Integer, None] = None, width: typing.Union[base.Integer, None] = None, height: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, caption: typing.Union[base.String, None] = None, parse_mode: typing.Union[base.String, None] = None, disable_notification: typing.Union[base.Boolean, None] = None, @@ -575,6 +586,9 @@ class Message(base.TelegramObject): :type width: :obj:`typing.Union[base.Integer, None]` :param height: Video height :type height: :obj:`typing.Union[base.Integer, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -596,6 +610,7 @@ class Message(base.TelegramObject): duration=duration, width=width, height=height, + thumb=thumb, caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, @@ -663,6 +678,7 @@ class Message(base.TelegramObject): video_note: typing.Union[base.InputFile, base.String], duration: typing.Union[base.Integer, None] = None, length: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Union[base.Boolean, None] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -685,6 +701,9 @@ class Message(base.TelegramObject): :type duration: :obj:`typing.Union[base.Integer, None]` :param length: Video width and height :type length: :obj:`typing.Union[base.Integer, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, @@ -700,6 +719,7 @@ class Message(base.TelegramObject): video_note=video_note, duration=duration, length=length, + thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup, @@ -1058,6 +1078,7 @@ class Message(base.TelegramObject): duration: typing.Union[base.Integer, None] = None, performer: typing.Union[base.String, None] = None, title: typing.Union[base.String, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Union[base.Boolean, None] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1089,6 +1110,9 @@ class Message(base.TelegramObject): :type performer: :obj:`typing.Union[base.String, None]` :param title: Track name :type title: :obj:`typing.Union[base.String, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, @@ -1107,6 +1131,7 @@ class Message(base.TelegramObject): duration=duration, performer=performer, title=title, + thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup, @@ -1150,7 +1175,7 @@ class Message(base.TelegramObject): :param height: Animation height :type height: :obj:`typing.Union[base.Integer, None]` :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. - A thumbnail‘s width and height should not exceed 90. + A thumbnail‘s width and height should not exceed 320. :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Animation caption (may also be used when resending animation by file_id), 0-1024 characters :type caption: :obj:`typing.Union[base.String, None]` @@ -1184,6 +1209,7 @@ class Message(base.TelegramObject): async def reply_document( self, document: typing.Union[base.InputFile, base.String], + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, caption: typing.Union[base.String, None] = None, parse_mode: typing.Union[base.String, None] = None, disable_notification: typing.Union[base.Boolean, None] = None, @@ -1205,6 +1231,9 @@ class Message(base.TelegramObject): :param document: File to send. :type document: :obj:`typing.Union[base.InputFile, base.String]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Document caption (may also be used when resending documents by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -1223,6 +1252,7 @@ class Message(base.TelegramObject): return await self.bot.send_document( chat_id=self.chat.id, document=document, + thumb=thumb, caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, @@ -1236,6 +1266,7 @@ class Message(base.TelegramObject): duration: typing.Union[base.Integer, None] = None, width: typing.Union[base.Integer, None] = None, height: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, caption: typing.Union[base.String, None] = None, parse_mode: typing.Union[base.String, None] = None, disable_notification: typing.Union[base.Boolean, None] = None, @@ -1262,6 +1293,9 @@ class Message(base.TelegramObject): :type width: :obj:`typing.Union[base.Integer, None]` :param height: Video height :type height: :obj:`typing.Union[base.Integer, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -1283,6 +1317,7 @@ class Message(base.TelegramObject): duration=duration, width=width, height=height, + thumb=thumb, caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, @@ -1350,6 +1385,7 @@ class Message(base.TelegramObject): video_note: typing.Union[base.InputFile, base.String], duration: typing.Union[base.Integer, None] = None, length: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Union[base.Boolean, None] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1372,6 +1408,9 @@ class Message(base.TelegramObject): :type duration: :obj:`typing.Union[base.Integer, None]` :param length: Video width and height :type length: :obj:`typing.Union[base.Integer, None]` + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 320. + :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, @@ -1387,6 +1426,7 @@ class Message(base.TelegramObject): video_note=video_note, duration=duration, length=length, + thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup, From d179789ea79b35bfb0443d896292a4355564cb4f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 29 Jun 2020 21:56:35 +0300 Subject: [PATCH 19/39] feat: mongo motor (#368) * initial commit * replacement by motor * Delete settings.json * fix: split to several files * feat: add test for deleted file Co-authored-by: morz Co-authored-by: morzik45 <46727779+morzik45@users.noreply.github.com> --- aiogram/contrib/fsm_storage/mongo.py | 201 +--------------- aiogram/contrib/fsm_storage/mongo_aiomongo.py | 207 +++++++++++++++++ aiogram/contrib/fsm_storage/mongo_motor.py | 217 ++++++++++++++++++ tests/contrib/fsm_storage/test_aiomongo.py | 15 ++ 4 files changed, 440 insertions(+), 200 deletions(-) create mode 100644 aiogram/contrib/fsm_storage/mongo_aiomongo.py create mode 100644 aiogram/contrib/fsm_storage/mongo_motor.py create mode 100644 tests/contrib/fsm_storage/test_aiomongo.py diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index 9ec18090..8d2a8cea 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -1,200 +1 @@ -""" -This module has mongo storage for finite-state machine - based on `aiomongo AioMongoClient: - if isinstance(self._mongo, AioMongoClient): - return self._mongo - - uri = 'mongodb://' - - # set username + password - if self._username and self._password: - uri += f'{self._username}:{self._password}@' - - # set host and port (optional) - uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' - - # define and return client - self._mongo = await aiomongo.create_client(uri) - return self._mongo - - async def get_db(self) -> Database: - """ - Get Mongo db - - This property is awaitable. - """ - if isinstance(self._db, Database): - return self._db - - mongo = await self.get_client() - self._db = mongo.get_database(self._db_name) - - if self._index: - await self.apply_index(self._db) - return self._db - - @staticmethod - async def apply_index(db): - for collection in COLLECTIONS: - await db[collection].create_index(keys=[('chat', 1), ('user', 1)], - name="chat_user_idx", unique=True, background=True) - - async def close(self): - if self._mongo: - self._mongo.close() - - async def wait_closed(self): - if self._mongo: - return await self._mongo.wait_closed() - return True - - async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - state: Optional[AnyStr] = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - if state is None: - await db[STATE].delete_one(filter={'chat': chat, 'user': user}) - else: - await db[STATE].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'state': state}}, upsert=True) - - async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[str] = None) -> Optional[str]: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) - - return result.get('state') if result else default - - async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[DATA].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'data': data}}, upsert=True) - - async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) - - return result.get('data') if result else default or {} - - async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None, **kwargs): - if data is None: - data = {} - temp_data = await self.get_data(chat=chat, user=user, default={}) - temp_data.update(data, **kwargs) - await self.set_data(chat=chat, user=user, data=temp_data) - - def has_bucket(self): - return True - - async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) - return result.get('bucket') if result else default or {} - - async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - bucket: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'bucket': bucket}}, upsert=True) - - async def update_bucket(self, *, chat: Union[str, int, None] = None, - user: Union[str, int, None] = None, - bucket: Dict = None, **kwargs): - if bucket is None: - bucket = {} - temp_bucket = await self.get_bucket(chat=chat, user=user) - temp_bucket.update(bucket, **kwargs) - await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) - - async def reset_all(self, full=True): - """ - Reset states in DB - - :param full: clean DB or clean only states - :return: - """ - db = await self.get_db() - - await db[STATE].drop() - - if full: - await db[DATA].drop() - await db[BUCKET].drop() - - async def get_states_list(self) -> List[Tuple[int, int]]: - """ - Get list of all stored chat's and user's - - :return: list of tuples where first element is chat id and second is user id - """ - db = await self.get_db() - result = [] - - items = await db[STATE].find().to_list() - for item in items: - result.append( - (int(item['chat']), int(item['user'])) - ) - - return result +from .mongo_aiomongo import MongoStorage diff --git a/aiogram/contrib/fsm_storage/mongo_aiomongo.py b/aiogram/contrib/fsm_storage/mongo_aiomongo.py new file mode 100644 index 00000000..c9f94ae5 --- /dev/null +++ b/aiogram/contrib/fsm_storage/mongo_aiomongo.py @@ -0,0 +1,207 @@ +""" +This module has mongo storage for finite-state machine + based on `aiomongo AioMongoClient: + if isinstance(self._mongo, AioMongoClient): + return self._mongo + + uri = 'mongodb://' + + # set username + password + if self._username and self._password: + uri += f'{self._username}:{self._password}@' + + # set host and port (optional) + uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' + + # define and return client + self._mongo = await aiomongo.create_client(uri) + return self._mongo + + async def get_db(self) -> Database: + """ + Get Mongo db + + This property is awaitable. + """ + if isinstance(self._db, Database): + return self._db + + mongo = await self.get_client() + self._db = mongo.get_database(self._db_name) + + if self._index: + await self.apply_index(self._db) + return self._db + + @staticmethod + async def apply_index(db): + for collection in COLLECTIONS: + await db[collection].create_index(keys=[('chat', 1), ('user', 1)], + name="chat_user_idx", unique=True, background=True) + + async def close(self): + if self._mongo: + self._mongo.close() + + async def wait_closed(self): + if self._mongo: + return await self._mongo.wait_closed() + return True + + async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + state: Optional[AnyStr] = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + if state is None: + await db[STATE].delete_one(filter={'chat': chat, 'user': user}) + else: + await db[STATE].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'state': state}}, upsert=True) + + async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[str] = None) -> Optional[str]: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) + + return result.get('state') if result else default + + async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[DATA].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'data': data}}, upsert=True) + + async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) + + return result.get('data') if result else default or {} + + async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None, **kwargs): + if data is None: + data = {} + temp_data = await self.get_data(chat=chat, user=user, default={}) + temp_data.update(data, **kwargs) + await self.set_data(chat=chat, user=user, data=temp_data) + + def has_bucket(self): + return True + + async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) + return result.get('bucket') if result else default or {} + + async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + bucket: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'bucket': bucket}}, upsert=True) + + async def update_bucket(self, *, chat: Union[str, int, None] = None, + user: Union[str, int, None] = None, + bucket: Dict = None, **kwargs): + if bucket is None: + bucket = {} + temp_bucket = await self.get_bucket(chat=chat, user=user) + temp_bucket.update(bucket, **kwargs) + await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) + + async def reset_all(self, full=True): + """ + Reset states in DB + + :param full: clean DB or clean only states + :return: + """ + db = await self.get_db() + + await db[STATE].drop() + + if full: + await db[DATA].drop() + await db[BUCKET].drop() + + async def get_states_list(self) -> List[Tuple[int, int]]: + """ + Get list of all stored chat's and user's + + :return: list of tuples where first element is chat id and second is user id + """ + db = await self.get_db() + result = [] + + items = await db[STATE].find().to_list() + for item in items: + result.append( + (int(item['chat']), int(item['user'])) + ) + + return result diff --git a/aiogram/contrib/fsm_storage/mongo_motor.py b/aiogram/contrib/fsm_storage/mongo_motor.py new file mode 100644 index 00000000..a7601cc4 --- /dev/null +++ b/aiogram/contrib/fsm_storage/mongo_motor.py @@ -0,0 +1,217 @@ +""" +This module has mongo storage for finite-state machine + based on `motor `_ driver +""" + +from typing import Union, Dict, Optional, List, Tuple, AnyStr + +import pymongo + +try: + import motor + from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +except ModuleNotFoundError as e: + import warnings + warnings.warn("Install motor with `pip install motor`") + raise e + +from ...dispatcher.storage import BaseStorage + +STATE = 'aiogram_state' +DATA = 'aiogram_data' +BUCKET = 'aiogram_bucket' +COLLECTIONS = (STATE, DATA, BUCKET) + + +class MongoStorage(BaseStorage): + """ + Mongo-based storage for FSM. + Usage: + + .. code-block:: python3 + + storage = MongoStorage(host='localhost', port=27017, db_name='aiogram_fsm') + dp = Dispatcher(bot, storage=storage) + + And need to close Mongo client connections when shutdown + + .. code-block:: python3 + + await dp.storage.close() + await dp.storage.wait_closed() + + """ + + def __init__(self, host='localhost', port=27017, db_name='aiogram_fsm', uri=None, + username=None, password=None, index=True, **kwargs): + self._host = host + self._port = port + self._db_name: str = db_name + self._uri = uri + self._username = username + self._password = password + self._kwargs = kwargs + + self._mongo: Optional[AsyncIOMotorClient] = None + self._db: Optional[AsyncIOMotorDatabase] = None + + self._index = index + + async def get_client(self) -> AsyncIOMotorClient: + if isinstance(self._mongo, AsyncIOMotorClient): + return self._mongo + + if self._uri: + try: + self._mongo = AsyncIOMotorClient(self._uri) + except pymongo.errors.ConfigurationError as e: + if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]: + import logging + logger = logging.getLogger("aiogram") + logger.warning("Run `pip install dnspython==1.16.0` in order to fix ConfigurationError. More information: https://github.com/mongodb/mongo-python-driver/pull/423#issuecomment-528998245") + raise e + return self._mongo + + uri = 'mongodb://' + + # set username + password + if self._username and self._password: + uri += f'{self._username}:{self._password}@' + + # set host and port (optional) + uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' + + # define and return client + self._mongo = AsyncIOMotorClient(uri) + return self._mongo + + async def get_db(self) -> AsyncIOMotorDatabase: + """ + Get Mongo db + + This property is awaitable. + """ + if isinstance(self._db, AsyncIOMotorDatabase): + return self._db + + mongo = await self.get_client() + self._db = mongo.get_database(self._db_name) + + if self._index: + await self.apply_index(self._db) + return self._db + + @staticmethod + async def apply_index(db): + for collection in COLLECTIONS: + await db[collection].create_index(keys=[('chat', 1), ('user', 1)], + name="chat_user_idx", unique=True, background=True) + + async def close(self): + if self._mongo: + self._mongo.close() + + async def wait_closed(self): + return True + + async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + state: Optional[AnyStr] = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + if state is None: + await db[STATE].delete_one(filter={'chat': chat, 'user': user}) + else: + await db[STATE].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'state': state}}, upsert=True) + + async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[str] = None) -> Optional[str]: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) + + return result.get('state') if result else default + + async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[DATA].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'data': data}}, upsert=True) + + async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) + + return result.get('data') if result else default or {} + + async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None, **kwargs): + if data is None: + data = {} + temp_data = await self.get_data(chat=chat, user=user, default={}) + temp_data.update(data, **kwargs) + await self.set_data(chat=chat, user=user, data=temp_data) + + def has_bucket(self): + return True + + async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) + return result.get('bucket') if result else default or {} + + async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + bucket: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'bucket': bucket}}, upsert=True) + + async def update_bucket(self, *, chat: Union[str, int, None] = None, + user: Union[str, int, None] = None, + bucket: Dict = None, **kwargs): + if bucket is None: + bucket = {} + temp_bucket = await self.get_bucket(chat=chat, user=user) + temp_bucket.update(bucket, **kwargs) + await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) + + async def reset_all(self, full=True): + """ + Reset states in DB + + :param full: clean DB or clean only states + :return: + """ + db = await self.get_db() + + await db[STATE].drop() + + if full: + await db[DATA].drop() + await db[BUCKET].drop() + + async def get_states_list(self) -> List[Tuple[int, int]]: + """ + Get list of all stored chat's and user's + + :return: list of tuples where first element is chat id and second is user id + """ + db = await self.get_db() + result = [] + + items = await db[STATE].find().to_list() + for item in items: + result.append( + (int(item['chat']), int(item['user'])) + ) + + return result diff --git a/tests/contrib/fsm_storage/test_aiomongo.py b/tests/contrib/fsm_storage/test_aiomongo.py new file mode 100644 index 00000000..4940711d --- /dev/null +++ b/tests/contrib/fsm_storage/test_aiomongo.py @@ -0,0 +1,15 @@ +import importlib + +import aiogram + + +def test_file_deleted(): + try: + major, minor, _ = aiogram.__version__.split(".") + except ValueError: # raised if version is major.minor + major, minor = aiogram.__version__.split(".") + if major == "2" and int(minor) >= 11: + mongo_aiomongo = importlib.util.find_spec("aiogram.contrib.fsm_storage.mongo_aiomongo") + assert mongo_aiomongo is False, "Remove aiogram.contrib.fsm_storage.mongo_aiomongo file, and replace storage " \ + "in aiogram.contrib.fsm_storage.mongo with storage " \ + "from aiogram.contrib.fsm_storage.mongo_motor" From 81b36bd19249dbb0d4eb8a0df8e7d5fb0ad16ae4 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 2 Jul 2020 14:17:48 +0300 Subject: [PATCH 20/39] feat: add ChatType builtin filter (#356) * feat: ChatTypesFilter * feat: add example of usage * feat: docs * fix: add import in filters/__init__ * fix: remove some of event_handlers * fix * fix imports * fix: rename to ChatTypeFilter * fix: rename argument to chat_type fix: rename example file name fix: str is container also lol. example fixed also * fix: respect type hints * fix: add warning with respect to type hint * fix: use warnings instead of logging --- aiogram/dispatcher/dispatcher.py | 10 ++++-- aiogram/dispatcher/filters/__init__.py | 4 ++- aiogram/dispatcher/filters/builtin.py | 25 +++++++++++++-- aiogram/types/chat.py | 6 ++++ docs/source/dispatcher/filters.rst | 8 +++++ examples/chat_type_filter.py | 42 ++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 examples/chat_type_filter.py diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 5dfc8cf9..d1834b2a 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -11,8 +11,7 @@ from aiohttp.helpers import sentinel from aiogram.utils.deprecated import renamed_argument from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \ RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter, \ - AbstractFilter -from .filters.builtin import IsSenderContact + IsSenderContact, ChatTypeFilter, AbstractFilter from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -167,6 +166,13 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.channel_post_handlers, self.edited_channel_post_handlers ]) + filters_factory.bind(ChatTypeFilter, event_handlers=[ + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, + self.callback_query_handlers, + ]) def __del__(self): self.stop_polling() diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index edd1959a..5f839662 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,6 +1,7 @@ from .builtin import Command, CommandHelp, CommandPrivacy, CommandSettings, CommandStart, ContentTypeFilter, \ ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, \ - Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact, ForwardedMessageFilter + Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact, ForwardedMessageFilter, \ + ChatTypeFilter from .factory import FiltersFactory from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \ check_filters, get_filter_spec, get_filters_spec @@ -33,4 +34,5 @@ __all__ = [ 'execute_filter', 'check_filters', 'ForwardedMessageFilter', + 'ChatTypeFilter', ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index c59d9b0d..cb1c4815 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,6 +1,7 @@ import inspect import re import typing +import warnings from contextvars import ContextVar from dataclasses import dataclass, field from typing import Any, Dict, Iterable, Optional, Union @@ -9,8 +10,7 @@ from babel.support import LazyProxy from aiogram import types from aiogram.dispatcher.filters.filters import BoundFilter, Filter -from aiogram.types import CallbackQuery, Message, InlineQuery, Poll, ChatType - +from aiogram.types import CallbackQuery, ChatType, InlineQuery, Message, Poll ChatIDArgumentType = typing.Union[typing.Iterable[typing.Union[int, str]], str, int] @@ -691,3 +691,24 @@ class ForwardedMessageFilter(BoundFilter): async def check(self, message: Message): return bool(getattr(message, "forward_date")) is self.is_forwarded + + +class ChatTypeFilter(BoundFilter): + key = 'chat_type' + + def __init__(self, chat_type: typing.Container[ChatType]): + if isinstance(chat_type, str): + chat_type = {chat_type} + + self.chat_type: typing.Set[str] = set(chat_type) + + async def check(self, obj: Union[Message, CallbackQuery]): + if isinstance(obj, Message): + obj = obj.chat + elif isinstance(obj, CallbackQuery): + obj = obj.message.chat + else: + warnings.warn("ChatTypeFilter doesn't support %s as input", type(obj)) + return False + + return obj.type in self.chat_type diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 4a7287d8..28cc5ed0 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -10,6 +10,7 @@ from .chat_member import ChatMember from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto from .input_file import InputFile +from ..utils.deprecated import deprecated class Chat(base.TelegramObject): @@ -512,6 +513,7 @@ class ChatType(helper.Helper): return obj.type in chat_types @classmethod + @deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0") def is_private(cls, obj) -> bool: """ Check chat is private @@ -522,6 +524,7 @@ class ChatType(helper.Helper): return cls._check(obj, [cls.PRIVATE]) @classmethod + @deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0") def is_group(cls, obj) -> bool: """ Check chat is group @@ -532,6 +535,7 @@ class ChatType(helper.Helper): return cls._check(obj, [cls.GROUP]) @classmethod + @deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0") def is_super_group(cls, obj) -> bool: """ Check chat is super-group @@ -542,6 +546,7 @@ class ChatType(helper.Helper): return cls._check(obj, [cls.SUPER_GROUP]) @classmethod + @deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0") def is_group_or_super_group(cls, obj) -> bool: """ Check chat is group or super-group @@ -552,6 +557,7 @@ class ChatType(helper.Helper): return cls._check(obj, [cls.GROUP, cls.SUPER_GROUP]) @classmethod + @deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0") def is_channel(cls, obj) -> bool: """ Check chat is channel diff --git a/docs/source/dispatcher/filters.rst b/docs/source/dispatcher/filters.rst index 3681dfcb..f53a4c95 100644 --- a/docs/source/dispatcher/filters.rst +++ b/docs/source/dispatcher/filters.rst @@ -149,6 +149,14 @@ ForwardedMessageFilter :show-inheritance: +ChatTypeFilter +------------- + +.. autoclass:: aiogram.dispatcher.filters.filters.ChatTypeFilter + :members: + :show-inheritance: + + Making own filters (Custom filters) =================================== diff --git a/examples/chat_type_filter.py b/examples/chat_type_filter.py new file mode 100644 index 00000000..08bb1858 --- /dev/null +++ b/examples/chat_type_filter.py @@ -0,0 +1,42 @@ +""" +This is an example with usage of ChatTypeFilter +It filters incoming object based on type of its chat type +""" + +import logging + +from aiogram import Bot, Dispatcher, executor, types +from aiogram.dispatcher.handler import SkipHandler +from aiogram.types import ChatType + +API_TOKEN = 'BOT TOKEN HERE' + +# Configure logging +logging.basicConfig(level=logging.INFO) + +# Initialize bot and dispatcher +bot = Bot(token=API_TOKEN) +dp = Dispatcher(bot) + + +@dp.message_handler(chat_type=[ChatType.PRIVATE, ChatType.CHANNEL]) +async def send_welcome(message: types.Message): + """ + This handler will be called when user sends `/start` or `/help` command + """ + await message.reply("Hi!\nI'm hearing your messages in private chats and channels") + + # propagate message to the next handler + raise SkipHandler + + +@dp.message_handler(chat_type=ChatType.PRIVATE) +async def send_welcome(message: types.Message): + """ + This handler will be called when user sends `/start` or `/help` command + """ + await message.reply("Hi!\nI'm hearing your messages only in private chats") + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) From 5586e5110dded557cdb8cb69af42ad60c6dd3887 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 5 Jul 2020 03:00:00 +0300 Subject: [PATCH 21/39] fix: remove aiomongo completely (#380) --- aiogram/contrib/fsm_storage/mongo.py | 218 +++++++++++++++++- aiogram/contrib/fsm_storage/mongo_aiomongo.py | 207 ----------------- aiogram/contrib/fsm_storage/mongo_motor.py | 217 ----------------- tests/contrib/fsm_storage/test_aiomongo.py | 15 -- 4 files changed, 217 insertions(+), 440 deletions(-) delete mode 100644 aiogram/contrib/fsm_storage/mongo_aiomongo.py delete mode 100644 aiogram/contrib/fsm_storage/mongo_motor.py delete mode 100644 tests/contrib/fsm_storage/test_aiomongo.py diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index 8d2a8cea..a7601cc4 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -1 +1,217 @@ -from .mongo_aiomongo import MongoStorage +""" +This module has mongo storage for finite-state machine + based on `motor `_ driver +""" + +from typing import Union, Dict, Optional, List, Tuple, AnyStr + +import pymongo + +try: + import motor + from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +except ModuleNotFoundError as e: + import warnings + warnings.warn("Install motor with `pip install motor`") + raise e + +from ...dispatcher.storage import BaseStorage + +STATE = 'aiogram_state' +DATA = 'aiogram_data' +BUCKET = 'aiogram_bucket' +COLLECTIONS = (STATE, DATA, BUCKET) + + +class MongoStorage(BaseStorage): + """ + Mongo-based storage for FSM. + Usage: + + .. code-block:: python3 + + storage = MongoStorage(host='localhost', port=27017, db_name='aiogram_fsm') + dp = Dispatcher(bot, storage=storage) + + And need to close Mongo client connections when shutdown + + .. code-block:: python3 + + await dp.storage.close() + await dp.storage.wait_closed() + + """ + + def __init__(self, host='localhost', port=27017, db_name='aiogram_fsm', uri=None, + username=None, password=None, index=True, **kwargs): + self._host = host + self._port = port + self._db_name: str = db_name + self._uri = uri + self._username = username + self._password = password + self._kwargs = kwargs + + self._mongo: Optional[AsyncIOMotorClient] = None + self._db: Optional[AsyncIOMotorDatabase] = None + + self._index = index + + async def get_client(self) -> AsyncIOMotorClient: + if isinstance(self._mongo, AsyncIOMotorClient): + return self._mongo + + if self._uri: + try: + self._mongo = AsyncIOMotorClient(self._uri) + except pymongo.errors.ConfigurationError as e: + if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]: + import logging + logger = logging.getLogger("aiogram") + logger.warning("Run `pip install dnspython==1.16.0` in order to fix ConfigurationError. More information: https://github.com/mongodb/mongo-python-driver/pull/423#issuecomment-528998245") + raise e + return self._mongo + + uri = 'mongodb://' + + # set username + password + if self._username and self._password: + uri += f'{self._username}:{self._password}@' + + # set host and port (optional) + uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' + + # define and return client + self._mongo = AsyncIOMotorClient(uri) + return self._mongo + + async def get_db(self) -> AsyncIOMotorDatabase: + """ + Get Mongo db + + This property is awaitable. + """ + if isinstance(self._db, AsyncIOMotorDatabase): + return self._db + + mongo = await self.get_client() + self._db = mongo.get_database(self._db_name) + + if self._index: + await self.apply_index(self._db) + return self._db + + @staticmethod + async def apply_index(db): + for collection in COLLECTIONS: + await db[collection].create_index(keys=[('chat', 1), ('user', 1)], + name="chat_user_idx", unique=True, background=True) + + async def close(self): + if self._mongo: + self._mongo.close() + + async def wait_closed(self): + return True + + async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + state: Optional[AnyStr] = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + if state is None: + await db[STATE].delete_one(filter={'chat': chat, 'user': user}) + else: + await db[STATE].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'state': state}}, upsert=True) + + async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[str] = None) -> Optional[str]: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) + + return result.get('state') if result else default + + async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[DATA].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'data': data}}, upsert=True) + + async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) + + return result.get('data') if result else default or {} + + async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None, **kwargs): + if data is None: + data = {} + temp_data = await self.get_data(chat=chat, user=user, default={}) + temp_data.update(data, **kwargs) + await self.set_data(chat=chat, user=user, data=temp_data) + + def has_bucket(self): + return True + + async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) + return result.get('bucket') if result else default or {} + + async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + bucket: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'bucket': bucket}}, upsert=True) + + async def update_bucket(self, *, chat: Union[str, int, None] = None, + user: Union[str, int, None] = None, + bucket: Dict = None, **kwargs): + if bucket is None: + bucket = {} + temp_bucket = await self.get_bucket(chat=chat, user=user) + temp_bucket.update(bucket, **kwargs) + await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) + + async def reset_all(self, full=True): + """ + Reset states in DB + + :param full: clean DB or clean only states + :return: + """ + db = await self.get_db() + + await db[STATE].drop() + + if full: + await db[DATA].drop() + await db[BUCKET].drop() + + async def get_states_list(self) -> List[Tuple[int, int]]: + """ + Get list of all stored chat's and user's + + :return: list of tuples where first element is chat id and second is user id + """ + db = await self.get_db() + result = [] + + items = await db[STATE].find().to_list() + for item in items: + result.append( + (int(item['chat']), int(item['user'])) + ) + + return result diff --git a/aiogram/contrib/fsm_storage/mongo_aiomongo.py b/aiogram/contrib/fsm_storage/mongo_aiomongo.py deleted file mode 100644 index c9f94ae5..00000000 --- a/aiogram/contrib/fsm_storage/mongo_aiomongo.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -This module has mongo storage for finite-state machine - based on `aiomongo AioMongoClient: - if isinstance(self._mongo, AioMongoClient): - return self._mongo - - uri = 'mongodb://' - - # set username + password - if self._username and self._password: - uri += f'{self._username}:{self._password}@' - - # set host and port (optional) - uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' - - # define and return client - self._mongo = await aiomongo.create_client(uri) - return self._mongo - - async def get_db(self) -> Database: - """ - Get Mongo db - - This property is awaitable. - """ - if isinstance(self._db, Database): - return self._db - - mongo = await self.get_client() - self._db = mongo.get_database(self._db_name) - - if self._index: - await self.apply_index(self._db) - return self._db - - @staticmethod - async def apply_index(db): - for collection in COLLECTIONS: - await db[collection].create_index(keys=[('chat', 1), ('user', 1)], - name="chat_user_idx", unique=True, background=True) - - async def close(self): - if self._mongo: - self._mongo.close() - - async def wait_closed(self): - if self._mongo: - return await self._mongo.wait_closed() - return True - - async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - state: Optional[AnyStr] = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - if state is None: - await db[STATE].delete_one(filter={'chat': chat, 'user': user}) - else: - await db[STATE].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'state': state}}, upsert=True) - - async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[str] = None) -> Optional[str]: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) - - return result.get('state') if result else default - - async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[DATA].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'data': data}}, upsert=True) - - async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) - - return result.get('data') if result else default or {} - - async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None, **kwargs): - if data is None: - data = {} - temp_data = await self.get_data(chat=chat, user=user, default={}) - temp_data.update(data, **kwargs) - await self.set_data(chat=chat, user=user, data=temp_data) - - def has_bucket(self): - return True - - async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) - return result.get('bucket') if result else default or {} - - async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - bucket: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'bucket': bucket}}, upsert=True) - - async def update_bucket(self, *, chat: Union[str, int, None] = None, - user: Union[str, int, None] = None, - bucket: Dict = None, **kwargs): - if bucket is None: - bucket = {} - temp_bucket = await self.get_bucket(chat=chat, user=user) - temp_bucket.update(bucket, **kwargs) - await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) - - async def reset_all(self, full=True): - """ - Reset states in DB - - :param full: clean DB or clean only states - :return: - """ - db = await self.get_db() - - await db[STATE].drop() - - if full: - await db[DATA].drop() - await db[BUCKET].drop() - - async def get_states_list(self) -> List[Tuple[int, int]]: - """ - Get list of all stored chat's and user's - - :return: list of tuples where first element is chat id and second is user id - """ - db = await self.get_db() - result = [] - - items = await db[STATE].find().to_list() - for item in items: - result.append( - (int(item['chat']), int(item['user'])) - ) - - return result diff --git a/aiogram/contrib/fsm_storage/mongo_motor.py b/aiogram/contrib/fsm_storage/mongo_motor.py deleted file mode 100644 index a7601cc4..00000000 --- a/aiogram/contrib/fsm_storage/mongo_motor.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -This module has mongo storage for finite-state machine - based on `motor `_ driver -""" - -from typing import Union, Dict, Optional, List, Tuple, AnyStr - -import pymongo - -try: - import motor - from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase -except ModuleNotFoundError as e: - import warnings - warnings.warn("Install motor with `pip install motor`") - raise e - -from ...dispatcher.storage import BaseStorage - -STATE = 'aiogram_state' -DATA = 'aiogram_data' -BUCKET = 'aiogram_bucket' -COLLECTIONS = (STATE, DATA, BUCKET) - - -class MongoStorage(BaseStorage): - """ - Mongo-based storage for FSM. - Usage: - - .. code-block:: python3 - - storage = MongoStorage(host='localhost', port=27017, db_name='aiogram_fsm') - dp = Dispatcher(bot, storage=storage) - - And need to close Mongo client connections when shutdown - - .. code-block:: python3 - - await dp.storage.close() - await dp.storage.wait_closed() - - """ - - def __init__(self, host='localhost', port=27017, db_name='aiogram_fsm', uri=None, - username=None, password=None, index=True, **kwargs): - self._host = host - self._port = port - self._db_name: str = db_name - self._uri = uri - self._username = username - self._password = password - self._kwargs = kwargs - - self._mongo: Optional[AsyncIOMotorClient] = None - self._db: Optional[AsyncIOMotorDatabase] = None - - self._index = index - - async def get_client(self) -> AsyncIOMotorClient: - if isinstance(self._mongo, AsyncIOMotorClient): - return self._mongo - - if self._uri: - try: - self._mongo = AsyncIOMotorClient(self._uri) - except pymongo.errors.ConfigurationError as e: - if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]: - import logging - logger = logging.getLogger("aiogram") - logger.warning("Run `pip install dnspython==1.16.0` in order to fix ConfigurationError. More information: https://github.com/mongodb/mongo-python-driver/pull/423#issuecomment-528998245") - raise e - return self._mongo - - uri = 'mongodb://' - - # set username + password - if self._username and self._password: - uri += f'{self._username}:{self._password}@' - - # set host and port (optional) - uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' - - # define and return client - self._mongo = AsyncIOMotorClient(uri) - return self._mongo - - async def get_db(self) -> AsyncIOMotorDatabase: - """ - Get Mongo db - - This property is awaitable. - """ - if isinstance(self._db, AsyncIOMotorDatabase): - return self._db - - mongo = await self.get_client() - self._db = mongo.get_database(self._db_name) - - if self._index: - await self.apply_index(self._db) - return self._db - - @staticmethod - async def apply_index(db): - for collection in COLLECTIONS: - await db[collection].create_index(keys=[('chat', 1), ('user', 1)], - name="chat_user_idx", unique=True, background=True) - - async def close(self): - if self._mongo: - self._mongo.close() - - async def wait_closed(self): - return True - - async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - state: Optional[AnyStr] = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - if state is None: - await db[STATE].delete_one(filter={'chat': chat, 'user': user}) - else: - await db[STATE].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'state': state}}, upsert=True) - - async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[str] = None) -> Optional[str]: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) - - return result.get('state') if result else default - - async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[DATA].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'data': data}}, upsert=True) - - async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) - - return result.get('data') if result else default or {} - - async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - data: Dict = None, **kwargs): - if data is None: - data = {} - temp_data = await self.get_data(chat=chat, user=user, default={}) - temp_data.update(data, **kwargs) - await self.set_data(chat=chat, user=user, data=temp_data) - - def has_bucket(self): - return True - - async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - default: Optional[dict] = None) -> Dict: - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) - return result.get('bucket') if result else default or {} - - async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, - bucket: Dict = None): - chat, user = self.check_address(chat=chat, user=user) - db = await self.get_db() - - await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'bucket': bucket}}, upsert=True) - - async def update_bucket(self, *, chat: Union[str, int, None] = None, - user: Union[str, int, None] = None, - bucket: Dict = None, **kwargs): - if bucket is None: - bucket = {} - temp_bucket = await self.get_bucket(chat=chat, user=user) - temp_bucket.update(bucket, **kwargs) - await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) - - async def reset_all(self, full=True): - """ - Reset states in DB - - :param full: clean DB or clean only states - :return: - """ - db = await self.get_db() - - await db[STATE].drop() - - if full: - await db[DATA].drop() - await db[BUCKET].drop() - - async def get_states_list(self) -> List[Tuple[int, int]]: - """ - Get list of all stored chat's and user's - - :return: list of tuples where first element is chat id and second is user id - """ - db = await self.get_db() - result = [] - - items = await db[STATE].find().to_list() - for item in items: - result.append( - (int(item['chat']), int(item['user'])) - ) - - return result diff --git a/tests/contrib/fsm_storage/test_aiomongo.py b/tests/contrib/fsm_storage/test_aiomongo.py deleted file mode 100644 index 4940711d..00000000 --- a/tests/contrib/fsm_storage/test_aiomongo.py +++ /dev/null @@ -1,15 +0,0 @@ -import importlib - -import aiogram - - -def test_file_deleted(): - try: - major, minor, _ = aiogram.__version__.split(".") - except ValueError: # raised if version is major.minor - major, minor = aiogram.__version__.split(".") - if major == "2" and int(minor) >= 11: - mongo_aiomongo = importlib.util.find_spec("aiogram.contrib.fsm_storage.mongo_aiomongo") - assert mongo_aiomongo is False, "Remove aiogram.contrib.fsm_storage.mongo_aiomongo file, and replace storage " \ - "in aiogram.contrib.fsm_storage.mongo with storage " \ - "from aiogram.contrib.fsm_storage.mongo_motor" From f9042927512711168954a6745719e5bfc998d7d3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 5 Jul 2020 03:08:38 +0300 Subject: [PATCH 22/39] fix: bug IDFilter checking message from channel (#376) * fix: bug IDFilter checking message from channel * chore: add mark.asyncio for tests which were ignored previously --- aiogram/dispatcher/filters/builtin.py | 4 +++- .../test_filters/test_builtin.py | 18 ++++++++++++++++-- tests/types/dataset.py | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index cb1c4815..268ef102 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -591,7 +591,9 @@ class IDFilter(Filter): async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]): if isinstance(obj, Message): - user_id = obj.from_user.id + user_id = None + if obj.from_user is not None: + user_id = obj.from_user.id chat_id = obj.chat.id elif isinstance(obj, CallbackQuery): user_id = obj.from_user.id diff --git a/tests/test_dispatcher/test_filters/test_builtin.py b/tests/test_dispatcher/test_filters/test_builtin.py index 4cfce465..4f05cb22 100644 --- a/tests/test_dispatcher/test_filters/test_builtin.py +++ b/tests/test_dispatcher/test_filters/test_builtin.py @@ -6,10 +6,10 @@ import pytest from aiogram.dispatcher.filters.builtin import ( Text, extract_chat_ids, - ChatIDArgumentType, ForwardedMessageFilter, + ChatIDArgumentType, ForwardedMessageFilter, IDFilter, ) from aiogram.types import Message -from tests.types.dataset import MESSAGE +from tests.types.dataset import MESSAGE, MESSAGE_FROM_CHANNEL class TestText: @@ -75,6 +75,8 @@ def test_extract_chat_ids(chat_id: ChatIDArgumentType, expected: Set[int]): class TestForwardedMessageFilter: + + @pytest.mark.asyncio async def test_filter_forwarded_messages(self): filter = ForwardedMessageFilter(is_forwarded=True) @@ -85,6 +87,7 @@ class TestForwardedMessageFilter: assert await filter.check(forwarded_message) assert not await filter.check(not_forwarded_message) + @pytest.mark.asyncio async def test_filter_not_forwarded_messages(self): filter = ForwardedMessageFilter(is_forwarded=False) @@ -94,3 +97,14 @@ class TestForwardedMessageFilter: assert await filter.check(not_forwarded_message) assert not await filter.check(forwarded_message) + + +class TestIDFilter: + + @pytest.mark.asyncio + async def test_chat_id_for_channels(self): + message_from_channel = Message(**MESSAGE_FROM_CHANNEL) + + filter = IDFilter(chat_id=message_from_channel.chat.id) + + assert await filter.check(message_from_channel) diff --git a/tests/types/dataset.py b/tests/types/dataset.py index 739e8e2c..a14ce316 100644 --- a/tests/types/dataset.py +++ b/tests/types/dataset.py @@ -409,6 +409,20 @@ MESSAGE_WITH_VOICE = { "voice": VOICE, } +CHANNEL = { + "type": "channel", + "username": "best_channel_ever", + "id": -1001065170817, +} + +MESSAGE_FROM_CHANNEL = { + "message_id": 123432, + "from": None, + "chat": CHANNEL, + "date": 1508768405, + "text": "Hi, world!", +} + PRE_CHECKOUT_QUERY = { "id": "262181558630368727", "from": USER, From caa0fe69d0e97061026d30441861e9843fc57cd4 Mon Sep 17 00:00:00 2001 From: Fenicu Date: Thu, 9 Jul 2020 14:27:19 +0300 Subject: [PATCH 23/39] A little documentation revision (#381) --- aiogram/types/input_media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index d42fac99..9a77658f 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -132,9 +132,9 @@ class InputMediaDocument(InputMedia): class InputMediaAudio(InputMedia): """ - Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. + Represents an audio file to be treated as music to be sent. - https://core.telegram.org/bots/api#inputmediaanimation + https://core.telegram.org/bots/api#inputmediaaudio """ duration: base.Integer = fields.Field() From d2cf73791945527c89108f29aad2ae3ed76fd2fb Mon Sep 17 00:00:00 2001 From: Ramzan Bekbulatov Date: Mon, 13 Jul 2020 15:28:38 +0300 Subject: [PATCH 24/39] new: add missed answer_poll and reply_poll (#384) --- aiogram/bot/bot.py | 2 + aiogram/types/message.py | 158 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index eb32d7f9..0cf01a4d 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -886,6 +886,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): Use this method to send a native poll. A native poll can't be sent to a private chat. On success, the sent Message is returned. + Source: https://core.telegram.org/bots/api#sendpoll + :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername). A native poll can't be sent to a private chat. diff --git a/aiogram/types/message.py b/aiogram/types/message.py index de53df6a..fedd656e 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -935,6 +935,85 @@ class Message(base.TelegramObject): reply_markup=reply_markup, ) + async def answer_poll( + self, + question: base.String, + options: typing.List[base.String], + is_anonymous: typing.Optional[base.Boolean] = None, + type: typing.Optional[base.String] = None, + allows_multiple_answers: typing.Optional[base.Boolean] = None, + correct_option_id: typing.Optional[base.Integer] = None, + explanation: typing.Optional[base.String] = None, + explanation_parse_mode: typing.Optional[base.String] = None, + open_period: typing.Union[base.Integer, None] = None, + close_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, + is_closed: typing.Optional[base.Boolean] = None, + disable_notification: typing.Optional[base.Boolean] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = False, + ) -> Message: + """ + Use this method to send a native poll. A native poll can't be sent to a private chat. + On success, the sent Message is returned. + + Source: https://core.telegram.org/bots/api#sendpoll + + :param question: Poll question, 1-255 characters + :type question: :obj:`base.String` + :param options: List of answer options, 2-10 strings 1-100 characters each + :type options: :obj:`typing.List[base.String]` + :param is_anonymous: True, if the poll needs to be anonymous, defaults to True + :type is_anonymous: :obj:`typing.Optional[base.Boolean]` + :param type: Poll type, “quiz” or “regular”, defaults to “regular” + :type type: :obj:`typing.Optional[base.String]` + :param allows_multiple_answers: True, if the poll allows multiple answers, ignored for polls in quiz mode, defaults to False + :type allows_multiple_answers: :obj:`typing.Optional[base.Boolean]` + :param correct_option_id: 0-based identifier of the correct answer option, required for polls in quiz mode + :type correct_option_id: :obj:`typing.Optional[base.Integer]` + :param explanation: Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-200 characters with at most 2 line feeds after entities parsing + :type explanation: :obj:`typing.Optional[base.String]` + :param explanation_parse_mode: Mode for parsing entities in the explanation. See formatting options for more details. + :type explanation_parse_mode: :obj:`typing.Optional[base.String]` + :param open_period: Amount of time in seconds the poll will be active after creation, 5-600. Can't be used together with close_date. + :type open_period: :obj:`typing.Union[base.Integer, None]` + :param close_date: Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least 5 and no more than 600 seconds in the future. Can't be used together with open_period. + :type close_date: :obj:`typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None]` + :param is_closed: Pass True, if the poll needs to be immediately closed + :type is_closed: :obj:`typing.Optional[base.Boolean]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :type disable_notification: :obj:`typing.Optional[Boolean]` + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, + custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, + types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` + :param reply: fill 'reply_to_message_id' + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` + """ + return await self.bot.send_poll( + chat_id=self.chat.id, + question=question, + options=options, + is_anonymous=is_anonymous, + type=type, + allows_multiple_answers=allows_multiple_answers, + correct_option_id=correct_option_id, + explanation=explanation, + explanation_parse_mode=explanation_parse_mode, + open_period=open_period, + close_date=close_date, + is_closed=is_closed, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) + async def answer_dice( self, emoji: typing.Union[base.String, None] = None, @@ -1604,6 +1683,85 @@ class Message(base.TelegramObject): reply_markup=reply_markup, ) + async def reply_poll( + self, + question: base.String, + options: typing.List[base.String], + is_anonymous: typing.Optional[base.Boolean] = None, + type: typing.Optional[base.String] = None, + allows_multiple_answers: typing.Optional[base.Boolean] = None, + correct_option_id: typing.Optional[base.Integer] = None, + explanation: typing.Optional[base.String] = None, + explanation_parse_mode: typing.Optional[base.String] = None, + open_period: typing.Union[base.Integer, None] = None, + close_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, + is_closed: typing.Optional[base.Boolean] = None, + disable_notification: typing.Optional[base.Boolean] = None, + reply_markup: typing.Union[ + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + None, + ] = None, + reply: base.Boolean = True, + ) -> Message: + """ + Use this method to send a native poll. A native poll can't be sent to a private chat. + On success, the sent Message is returned. + + Source: https://core.telegram.org/bots/api#sendpoll + + :param question: Poll question, 1-255 characters + :type question: :obj:`base.String` + :param options: List of answer options, 2-10 strings 1-100 characters each + :type options: :obj:`typing.List[base.String]` + :param is_anonymous: True, if the poll needs to be anonymous, defaults to True + :type is_anonymous: :obj:`typing.Optional[base.Boolean]` + :param type: Poll type, “quiz” or “regular”, defaults to “regular” + :type type: :obj:`typing.Optional[base.String]` + :param allows_multiple_answers: True, if the poll allows multiple answers, ignored for polls in quiz mode, defaults to False + :type allows_multiple_answers: :obj:`typing.Optional[base.Boolean]` + :param correct_option_id: 0-based identifier of the correct answer option, required for polls in quiz mode + :type correct_option_id: :obj:`typing.Optional[base.Integer]` + :param explanation: Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-200 characters with at most 2 line feeds after entities parsing + :type explanation: :obj:`typing.Optional[base.String]` + :param explanation_parse_mode: Mode for parsing entities in the explanation. See formatting options for more details. + :type explanation_parse_mode: :obj:`typing.Optional[base.String]` + :param open_period: Amount of time in seconds the poll will be active after creation, 5-600. Can't be used together with close_date. + :type open_period: :obj:`typing.Union[base.Integer, None]` + :param close_date: Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least 5 and no more than 600 seconds in the future. Can't be used together with open_period. + :type close_date: :obj:`typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None]` + :param is_closed: Pass True, if the poll needs to be immediately closed + :type is_closed: :obj:`typing.Optional[base.Boolean]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :type disable_notification: :obj:`typing.Optional[Boolean]` + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, + custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, + types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` + :param reply: fill 'reply_to_message_id' + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` + """ + return await self.bot.send_poll( + chat_id=self.chat.id, + question=question, + options=options, + is_anonymous=is_anonymous, + type=type, + allows_multiple_answers=allows_multiple_answers, + correct_option_id=correct_option_id, + explanation=explanation, + explanation_parse_mode=explanation_parse_mode, + open_period=open_period, + close_date=close_date, + is_closed=is_closed, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup, + ) + async def reply_sticker( self, sticker: typing.Union[base.InputFile, base.String], From 7a2fb9779f80abc8f948782efc2bf1021ab46a84 Mon Sep 17 00:00:00 2001 From: csd-oss <58642039+csd-oss@users.noreply.github.com> Date: Tue, 21 Jul 2020 21:29:33 +0300 Subject: [PATCH 25/39] Update dispatcher.py (#386) --- aiogram/dispatcher/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index d1834b2a..7a3aa5b3 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -445,7 +445,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): .. code-block:: python3 - @dp.message_handler(rexexp='^[a-z]+-[0-9]+') + @dp.message_handler(regexp='^[a-z]+-[0-9]+') async def msg_handler(message: types.Message): Filter messages by command regular expression: From 659e1dc2930a99e3192a831dad680a421cbdb5cd Mon Sep 17 00:00:00 2001 From: Ramzan Bekbulatov Date: Tue, 21 Jul 2020 21:30:20 +0300 Subject: [PATCH 26/39] new: command filter with ignore_caption parameter (#383) --- aiogram/dispatcher/filters/builtin.py | 31 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 268ef102..fcaaf786 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -37,7 +37,8 @@ class Command(Filter): def __init__(self, commands: Union[Iterable, str], prefixes: Union[Iterable, str] = '/', ignore_case: bool = True, - ignore_mention: bool = False): + ignore_mention: bool = False, + ignore_caption: bool = True): """ Filter can be initialized from filters factory or by simply creating instance of this class. @@ -55,6 +56,15 @@ class Command(Filter): :param ignore_case: Ignore case of the command :param ignore_mention: Ignore mention in command (By default this filter pass only the commands addressed to current bot) + :param ignore_caption: Ignore caption from message (in message types like photo, video, audio, etc) + By default is True. If you want check commands in captions, you also should set required content_types. + + Examples: + + .. code-block:: python + + @dp.message_handler(commands=['myCommand'], commands_ignore_caption=False, content_types=ContentType.ANY) + @dp.message_handler(Command(['myCommand'], ignore_caption=False), content_types=[ContentType.TEXT, ContentType.DOCUMENT]) """ if isinstance(commands, str): commands = (commands,) @@ -63,6 +73,7 @@ class Command(Filter): self.prefixes = prefixes self.ignore_case = ignore_case self.ignore_mention = ignore_mention + self.ignore_caption = ignore_caption @classmethod def validate(cls, full_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -73,7 +84,8 @@ class Command(Filter): - ``command`` - ``commands_prefix`` (will be passed as ``prefixes``) - - ``commands_ignore_mention`` (will be passed as ``ignore_mention`` + - ``commands_ignore_mention`` (will be passed as ``ignore_mention``) + - ``commands_ignore_caption`` (will be passed as ``ignore_caption``) :param full_config: :return: config or empty dict @@ -85,17 +97,20 @@ class Command(Filter): config['prefixes'] = full_config.pop('commands_prefix') if config and 'commands_ignore_mention' in full_config: config['ignore_mention'] = full_config.pop('commands_ignore_mention') + if config and 'commands_ignore_caption' in full_config: + config['ignore_caption'] = full_config.pop('commands_ignore_caption') return config async def check(self, message: types.Message): - return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention) + return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention, self.ignore_caption) - @staticmethod - async def check_command(message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False): - if not message.text: # Prevent to use with non-text content types + @classmethod + async def check_command(cls, message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False, ignore_caption=True): + text = message.text or (message.caption if not ignore_caption else None) + if not text: return False - full_command = message.text.split()[0] + full_command = text.split()[0] prefix, (command, _, mention) = full_command[0], full_command[1:].partition('@') if not ignore_mention and mention and (await message.bot.me).username.lower() != mention.lower(): @@ -105,7 +120,7 @@ class Command(Filter): if (command.lower() if ignore_case else command) not in commands: return False - return {'command': Command.CommandObj(command=command, prefix=prefix, mention=mention)} + return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention)} @dataclass class CommandObj: From 7f4f037f3e646d032fbfd8c9a93870eed47ff902 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 22 Jul 2020 23:56:46 +0300 Subject: [PATCH 27/39] Fix addStickerToSet method --- aiogram/bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 0cf01a4d..76dc3de8 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1955,7 +1955,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): files = {} prepare_file(payload, files, 'png_sticker', png_sticker) - prepare_file(payload, files, 'tgs_sticker', png_sticker) + prepare_file(payload, files, 'tgs_sticker', tgs_sticker) result = await self.request(api.Methods.ADD_STICKER_TO_SET, payload, files) return result From d1452b1620b7ef53d455ae4bd5d19acdcf490794 Mon Sep 17 00:00:00 2001 From: Abstract-X <44748702+Abstract-X@users.noreply.github.com> Date: Mon, 27 Jul 2020 03:43:56 +1100 Subject: [PATCH 28/39] Add preparing thumb in send_document method (#391) --- aiogram/bot/bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 76dc3de8..f427fa8c 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -398,6 +398,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): files = {} prepare_file(payload, files, 'document', document) + prepare_attachment(payload, files, 'thumb', thumb) result = await self.request(api.Methods.SEND_DOCUMENT, payload, files) return types.Message(**result) From 22094eb477711e644924eaeb2424c8cef20e637a Mon Sep 17 00:00:00 2001 From: Forden Date: Wed, 5 Aug 2020 15:06:02 +0300 Subject: [PATCH 29/39] Patch 1 (#398) * Update callback_data_factory.py * Update callback_data_factory_simple.py --- examples/callback_data_factory.py | 2 +- examples/callback_data_factory_simple.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/callback_data_factory.py b/examples/callback_data_factory.py index 9a8affe9..2335ea95 100644 --- a/examples/callback_data_factory.py +++ b/examples/callback_data_factory.py @@ -112,7 +112,7 @@ async def query_post_vote(query: types.CallbackQuery, callback_data: dict): @dp.errors_handler(exception=MessageNotModified) async def message_not_modified_handler(update, error): - return True + return True # errors_handler must return True if error was handled correctly if __name__ == '__main__': diff --git a/examples/callback_data_factory_simple.py b/examples/callback_data_factory_simple.py index 5fc9c548..a6d246d5 100644 --- a/examples/callback_data_factory_simple.py +++ b/examples/callback_data_factory_simple.py @@ -61,7 +61,7 @@ async def callback_vote_action(query: types.CallbackQuery, callback_data: dict): @dp.errors_handler(exception=MessageNotModified) # handle the cases when this exception raises async def message_not_modified_handler(update, error): - return True + return True # errors_handler must return True if error was handled correctly if __name__ == '__main__': From 4863675d28e841fb0ec8b85e77940599d7b5f013 Mon Sep 17 00:00:00 2001 From: Forden Date: Fri, 14 Aug 2020 17:09:45 +0300 Subject: [PATCH 30/39] Add exception MessageToPinNotFound (#404) --- aiogram/utils/exceptions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index cee2820a..a289be25 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -7,6 +7,7 @@ - MessageNotModified - MessageToForwardNotFound - MessageToDeleteNotFound + - MessageToPinNotFound - MessageIdentifierNotSpecified - MessageTextIsEmpty - MessageCantBeEdited @@ -182,6 +183,13 @@ class MessageToDeleteNotFound(MessageError): match = 'message to delete not found' +class MessageToPinNotFound(MessageError): + """ + Will be raised when you try to pin deleted or unknown message. + """ + match = 'message to pin not found' + + class MessageToReplyNotFound(MessageError): """ Will be raised when you try to reply to very old or deleted or unknown message. From 00cff4acf5af422cb2f1c5c34c0833d8d021301a Mon Sep 17 00:00:00 2001 From: Martin Winks <50446230+uwinx@users.noreply.github.com> Date: Sat, 22 Aug 2020 01:07:03 +0400 Subject: [PATCH 31/39] fix(handlerObj): fix parameter-spec solving (#408) --- aiogram/dispatcher/handler.py | 2 +- tests/test_dispatcher/test_handler.py | 66 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/test_dispatcher/test_handler.py diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index cd5e9b50..38219012 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -33,7 +33,7 @@ def _check_spec(spec: inspect.FullArgSpec, kwargs: dict): if spec.varkw: return kwargs - return {k: v for k, v in kwargs.items() if k in spec.args} + return {k: v for k, v in kwargs.items() if k in set(spec.args + spec.kwonlyargs)} class Handler: diff --git a/tests/test_dispatcher/test_handler.py b/tests/test_dispatcher/test_handler.py new file mode 100644 index 00000000..b823c8f8 --- /dev/null +++ b/tests/test_dispatcher/test_handler.py @@ -0,0 +1,66 @@ +import functools + +import pytest + +from aiogram.dispatcher.handler import Handler, _check_spec, _get_spec + + +def callback1(foo: int, bar: int, baz: int): + return locals() + + +async def callback2(foo: int, bar: int, baz: int): + return locals() + + +async def callback3(foo: int, **kwargs): + return locals() + + +class TestHandlerObj: + def test_init_decorated(self): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + @decorator + def callback1(foo, bar, baz): + pass + + @decorator + @decorator + def callback2(foo, bar, baz): + pass + + obj1 = Handler.HandlerObj(callback1, _get_spec(callback1)) + obj2 = Handler.HandlerObj(callback2, _get_spec(callback2)) + + assert set(obj1.spec.args) == {"foo", "bar", "baz"} + assert obj1.handler == callback1 + assert set(obj2.spec.args) == {"foo", "bar", "baz"} + assert obj2.handler == callback2 + + @pytest.mark.parametrize( + "callback,kwargs,result", + [ + pytest.param( + callback1, {"foo": 42, "spam": True, "baz": "fuz"}, {"foo": 42, "baz": "fuz"} + ), + pytest.param( + callback2, + {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, + {"foo": 42, "baz": "fuz", "bar": "test"}, + ), + pytest.param( + callback3, + {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, + {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, + ), + ], + ) + def test__check_spec(self, callback, kwargs, result): + spec = _get_spec(callback) + assert _check_spec(spec, kwargs) == result From d8c6214170a87cb46048f18623e6bbb96dc1b37c Mon Sep 17 00:00:00 2001 From: Daniil Kovalenko <40635760+WhiteMemory99@users.noreply.github.com> Date: Sun, 30 Aug 2020 05:06:48 +0700 Subject: [PATCH 32/39] Fix HTML characters escaping (#409) html.escape replaces " and ' characters by default, but it's not required by Telegram and causes unexpected behavior, quote=False argument fixes that. --- aiogram/utils/text_decorations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 3d22f637..4b3109af 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -159,7 +159,7 @@ 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): From 17eb8a56d06954a1540ebef0bdca80c8dd9cff4c Mon Sep 17 00:00:00 2001 From: unintended Date: Fri, 4 Sep 2020 18:08:15 +0300 Subject: [PATCH 33/39] Fix #413 parse entities positioning (#414) * fix entity positioning in parse_entities() #413 * add tests and small fixes --- aiogram/utils/text_decorations.py | 26 +++++++++++++++-------- tests/test_utils/test_text_decorations.py | 25 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 tests/test_utils/test_text_decorations.py diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 4b3109af..81592465 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -57,14 +57,14 @@ 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,15 +74,15 @@ 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 :]) + filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :]) ) yield self.apply_entity( entity, @@ -94,7 +94,15 @@ class TextDecoration(ABC): ) if offset < length: - yield self.quote(text[offset:length]) + yield self.quote(self._remove_surrogates(text[offset:length])) + + @staticmethod + def _add_surrogates(text: str): + return text.encode('utf-16-le') + + @staticmethod + def _remove_surrogates(text: bytes): + return text.decode('utf-16-le') @abstractmethod def link(self, value: str, link: str) -> str: # pragma: no cover diff --git a/tests/test_utils/test_text_decorations.py b/tests/test_utils/test_text_decorations.py new file mode 100644 index 00000000..dd0e595d --- /dev/null +++ b/tests/test_utils/test_text_decorations.py @@ -0,0 +1,25 @@ +from aiogram.types import MessageEntity, MessageEntityType +from aiogram.utils import text_decorations + + +class TestTextDecorations: + def test_unparse_entities_normal_text(self): + assert text_decorations.markdown_decoration.unparse( + "hi i'm bold and italic and still bold", + entities=[ + MessageEntity(offset=3, length=34, type=MessageEntityType.BOLD), + MessageEntity(offset=12, length=10, type=MessageEntityType.ITALIC), + ] + ) == "hi *i'm bold _and italic_\r and still bold*" + + def test_unparse_entities_emoji_text(self): + """ + emoji is encoded as two chars in json + """ + assert text_decorations.markdown_decoration.unparse( + "🚀 i'm bold and italic and still bold", + entities=[ + MessageEntity(offset=3, length=34, type=MessageEntityType.BOLD), + MessageEntity(offset=12, length=10, type=MessageEntityType.ITALIC), + ] + ) == "🚀 *i'm bold _and italic_\r and still bold*" From 9ab2945267aab4a9567fca6086d12fc21774e1a0 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Thu, 10 Sep 2020 22:31:54 +0300 Subject: [PATCH 34/39] fixed CallbackQuery.answer() returns nothing (#420) --- aiogram/types/callback_query.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/aiogram/types/callback_query.py b/aiogram/types/callback_query.py index 51ba1f17..e847bff8 100644 --- a/aiogram/types/callback_query.py +++ b/aiogram/types/callback_query.py @@ -54,8 +54,11 @@ class CallbackQuery(base.TelegramObject): :type cache_time: :obj:`typing.Union[base.Integer, None]` :return: On success, True is returned. :rtype: :obj:`base.Boolean`""" - await self.bot.answer_callback_query(callback_query_id=self.id, text=text, - show_alert=show_alert, url=url, cache_time=cache_time) + return await self.bot.answer_callback_query(callback_query_id=self.id, + text=text, + show_alert=show_alert, + url=url, + cache_time=cache_time) def __hash__(self): return hash(self.id) From 56ffa00c8ca8e80040fe9c58cf271d080fc46957 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Thu, 10 Sep 2020 22:32:56 +0300 Subject: [PATCH 35/39] I18n example patch (added docs and fixed typos) (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update i18n example 1. Replaced one file translation to all project folder translation. It's more usable case. 2. `For e.g.` --> `E.g.`. E.g. is short for `exempli gratia` which means simply “for example.” So if you write for e.g., you are in effect writing `for for example`. 3. `xargs` replased with serveral lines, `xargs` is not appliable for Microsoft Windows users. 4. Added info about translation tools. 5. Minor edits. * i18n middlaware typo fix * i18n example header typo fix --- aiogram/contrib/middlewares/i18n.py | 4 ++-- examples/i18n_example.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 63f54510..bb6d8003 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -59,13 +59,13 @@ class I18nMiddleware(BaseMiddleware): with open(mo_path, 'rb') as fp: translations[name] = gettext.GNUTranslations(fp) elif os.path.exists(mo_path[:-2] + 'po'): - raise RuntimeError(f"Found locale '{name} but this language is not compiled!") + raise RuntimeError(f"Found locale '{name}' but this language is not compiled!") return translations def reload(self): """ - Hot reload locles + Hot reload locales """ self.locales = self.find_locales() diff --git a/examples/i18n_example.py b/examples/i18n_example.py index b626d048..29b43210 100644 --- a/examples/i18n_example.py +++ b/examples/i18n_example.py @@ -1,8 +1,8 @@ """ -Internalize your bot +Internationalize your bot Step 1: extract texts - # pybabel extract i18n_example.py -o locales/mybot.pot + # pybabel extract --input-dirs=. -o locales/mybot.pot Some useful options: - Extract texts with pluralization support @@ -16,9 +16,14 @@ Step 1: extract texts - Set version # --version=2.2 -Step 2: create *.po files. For e.g. create en, ru, uk locales. - # echo {en,ru,uk} | xargs -n1 pybabel init -i locales/mybot.pot -d locales -D mybot -l -Step 3: translate texts +Step 2: create *.po files. E.g. create en, ru, uk locales. + # pybabel init -i locales/mybot.pot -d locales -D mybot -l en + # pybabel init -i locales/mybot.pot -d locales -D mybot -l ru + # pybabel init -i locales/mybot.pot -d locales -D mybot -l uk + +Step 3: translate texts located in locales/{language}/LC_MESSAGES/mybot.po + To open .po file you can use basic text editor or any PO editor, e.g. https://poedit.net/ + Step 4: compile translations # pybabel compile -d locales -D mybot @@ -27,7 +32,8 @@ Step 5: When you change the code of your bot you need to update po & mo files. command from step 1 Step 5.2: update po files # pybabel update -d locales -D mybot -i locales/mybot.pot - Step 5.3: update your translations + Step 5.3: update your translations + location and tools you know from step 3 Step 5.4: compile mo files command from step 4 """ @@ -92,5 +98,6 @@ async def cmd_like(message: types.Message, locale): # NOTE: This is comment for a translator await message.reply(__('Aiogram has {number} like!', 'Aiogram has {number} likes!', likes).format(number=likes)) + if __name__ == '__main__': executor.start_polling(dp, skip_updates=True) From fc5ccc9d5a04981d5debfb2af00d20e00339e293 Mon Sep 17 00:00:00 2001 From: Lamroy95 <50185460+Lamroy95@users.noreply.github.com> Date: Thu, 10 Sep 2020 22:33:25 +0300 Subject: [PATCH 36/39] Fixed docs Quick start page code blocks (#417) --- docs/source/quick_start.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index b0724a78..319886ce 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -28,13 +28,13 @@ If you want to handle all messages in the chat simply add handler without filter .. literalinclude:: ../../examples/echo_bot.py :language: python - :lines: 35-37 + :lines: 44-49 Last step: run long polling. .. literalinclude:: ../../examples/echo_bot.py :language: python - :lines: 40-41 + :lines: 52-53 Summary ------- @@ -42,4 +42,4 @@ Summary .. literalinclude:: ../../examples/echo_bot.py :language: python :linenos: - :lines: -19,27- + :lines: -27,43- From a529619d7999af98d157fdeaa46dc8eee77948e1 Mon Sep 17 00:00:00 2001 From: Martin Winks <50446230+uwinx@users.noreply.github.com> Date: Thu, 10 Sep 2020 23:34:20 +0400 Subject: [PATCH 37/39] hotfix(updates): CHOSEN_INLINE_RESULT is a correct API-term (#415) * hotfix(updates): CHOSEN_INLINE_RESULT is a correct API-term * feat(utils): deprecated descriptor deprecate CHOSEN_INLINE_QUERY and always return CHOSEN_INLINE_RESULT instead of incorrect value * fix(tests): remove example from test * fix(utils): use stacklevel=3 level on which descriptor is being called --- aiogram/types/update.py | 10 +++++++-- aiogram/utils/deprecated.py | 35 ++++++++++++++++++++++++++++- tests/test_utils/test_deprecated.py | 14 ++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/test_utils/test_deprecated.py diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 2146cb9d..9d1afacc 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -9,7 +9,7 @@ from .message import Message from .poll import Poll, PollAnswer from .pre_checkout_query import PreCheckoutQuery from .shipping_query import ShippingQuery -from ..utils import helper +from ..utils import helper, deprecated class Update(base.TelegramObject): @@ -55,9 +55,15 @@ class AllowedUpdates(helper.Helper): CHANNEL_POST = helper.ListItem() # channel_post EDITED_CHANNEL_POST = helper.ListItem() # edited_channel_post INLINE_QUERY = helper.ListItem() # inline_query - CHOSEN_INLINE_QUERY = helper.ListItem() # chosen_inline_result + CHOSEN_INLINE_RESULT = helper.ListItem() # chosen_inline_result CALLBACK_QUERY = helper.ListItem() # callback_query SHIPPING_QUERY = helper.ListItem() # shipping_query PRE_CHECKOUT_QUERY = helper.ListItem() # pre_checkout_query POLL = helper.ListItem() # poll POLL_ANSWER = helper.ListItem() # poll_answer + + CHOSEN_INLINE_QUERY = deprecated.DeprecatedReadOnlyClassVar( + "`CHOSEN_INLINE_QUERY` is a deprecated value for allowed update. " + "Use `CHOSEN_INLINE_RESULT`", + new_value_getter=lambda cls: cls.CHOSEN_INLINE_RESULT, + ) diff --git a/aiogram/utils/deprecated.py b/aiogram/utils/deprecated.py index 83a9034c..6d0d7ee3 100644 --- a/aiogram/utils/deprecated.py +++ b/aiogram/utils/deprecated.py @@ -2,7 +2,7 @@ import asyncio import inspect import warnings import functools -from typing import Callable +from typing import Callable, Generic, TypeVar, Type, Optional def deprecated(reason, stacklevel=2) -> Callable: @@ -129,3 +129,36 @@ def renamed_argument(old_name: str, new_name: str, until_version: str, stackleve return wrapped return decorator + + +_VT = TypeVar("_VT") +_OwnerCls = TypeVar("_OwnerCls") + + +class DeprecatedReadOnlyClassVar(Generic[_OwnerCls, _VT]): + """ + DeprecatedReadOnlyClassVar[Owner, ValueType] + + :param warning_message: Warning message when getter gets called + :param new_value_getter: Any callable with (owner_class: Type[Owner]) -> ValueType + signature that will be executed + + Usage example: + + >>> class MyClass: + ... some_attribute: DeprecatedReadOnlyClassVar[MyClass, int] = \ + ... DeprecatedReadOnlyClassVar( + ... "Warning message.", lambda owner: 15) + ... + >>> MyClass.some_attribute # does warning.warn with `Warning message` and returns 15 in the current case + """ + + __slots__ = "_new_value_getter", "_warning_message" + + def __init__(self, warning_message: str, new_value_getter: Callable[[_OwnerCls], _VT]): + self._warning_message = warning_message + self._new_value_getter = new_value_getter + + def __get__(self, instance: Optional[_OwnerCls], owner: Type[_OwnerCls]): + warn_deprecated(self._warning_message, stacklevel=3) + return self._new_value_getter(owner) diff --git a/tests/test_utils/test_deprecated.py b/tests/test_utils/test_deprecated.py new file mode 100644 index 00000000..114d6810 --- /dev/null +++ b/tests/test_utils/test_deprecated.py @@ -0,0 +1,14 @@ +import pytest + +from aiogram.utils.deprecated import DeprecatedReadOnlyClassVar + + +def test_DeprecatedReadOnlyClassVarCD(): + assert DeprecatedReadOnlyClassVar.__slots__ == ("_new_value_getter", "_warning_message") + + new_value_of_deprecated_cls_cd = "mpa" + pseudo_owner_cls = type("OpekaCla$$", (), {}) + deprecated_cd = DeprecatedReadOnlyClassVar("mopekaa", lambda owner: new_value_of_deprecated_cls_cd) + + with pytest.warns(DeprecationWarning): + assert deprecated_cd.__get__(None, pseudo_owner_cls) == new_value_of_deprecated_cls_cd From fce48e3127c645fbfe7b771d34c9175f3c41d2cf Mon Sep 17 00:00:00 2001 From: Daneel L <53001370+lovkiymusic@users.noreply.github.com> Date: Sun, 13 Sep 2020 22:07:14 +0300 Subject: [PATCH 38/39] comment of RedisStorage2 fix (#423) --- aiogram/contrib/fsm_storage/redis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index bf88eff7..74dd736c 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -208,7 +208,7 @@ class RedisStorage2(BaseStorage): .. code-block:: python3 - storage = RedisStorage('localhost', 6379, db=5, pool_size=10, prefix='my_fsm_key') + storage = RedisStorage2('localhost', 6379, db=5, pool_size=10, prefix='my_fsm_key') dp = Dispatcher(bot, storage=storage) And need to close Redis connection when shutdown From a936465f427b25c149e4e508c666fb5ed3de91d5 Mon Sep 17 00:00:00 2001 From: Gabben <43146729+gabbhack@users.noreply.github.com> Date: Mon, 14 Sep 2020 00:07:29 +0500 Subject: [PATCH 39/39] add missing attributes (#422) https://core.telegram.org/bots/api#animation --- aiogram/types/animation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aiogram/types/animation.py b/aiogram/types/animation.py index 78f5235a..b08089c1 100644 --- a/aiogram/types/animation.py +++ b/aiogram/types/animation.py @@ -15,6 +15,9 @@ class Animation(base.TelegramObject, mixins.Downloadable): file_id: base.String = fields.Field() file_unique_id: base.String = fields.Field() + width: base.Integer = fields.Field() + height: base.Integer = fields.Field() + duration: base.Integer = fields.Field() thumb: PhotoSize = fields.Field(base=PhotoSize) file_name: base.String = fields.Field() mime_type: base.String = fields.Field()