Bot API 5.7 and some new features (#834)

* Update API, added some new features

* Fixed unknown chat_action value

* Separate events from dispatcher messages

* Disabled cache for I18n LazyProxy

* Rework events isolation

* Added chat member status changed filter, update Bot API 5.7, other small changes

* Improve exceptions in chat member status filter

* Fixed tests, covered flags and events isolation modules

* Try to fix flake8 unused type ignore

* Fixed linter error

* Cover chat member updated filter

* Cover chat action sender

* Added docs for chat action util

* Try to fix tests for python <= 3.9

* Fixed headers

* Added docs for flags functionality

* Added docs for chat_member_updated filter

* Added change notes

* Update dependencies and fix mypy checks

* Bump version
This commit is contained in:
Alex Root Junior 2022-02-19 01:45:59 +02:00 committed by GitHub
parent ac7f2dc408
commit 7776cf9cf6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 2485 additions and 502 deletions

View file

@ -1 +1 @@
5.5
5.7

View file

@ -3,3 +3,4 @@ exclude_lines =
pragma: no cover
if TYPE_CHECKING:
@abstractmethod
@overload

1
CHANGES/830.misc Normal file
View file

@ -0,0 +1 @@
Logger name for processing events is changed to :code:`aiogram.events`.

1
CHANGES/835.misc Normal file
View file

@ -0,0 +1 @@
Added full support of Telegram Bot API 5.6 and 5.7

1
CHANGES/836.feature Normal file
View file

@ -0,0 +1 @@
Added possibility to add handler flags via decorator (like `pytest.mark` decorator but `aiogram.flags`)

3
CHANGES/837.feature Normal file
View file

@ -0,0 +1,3 @@
Added :code:`ChatActionSender` utility to automatically sends chat action while long process is running.
It also can be used as message middleware and can be customized via :code:`chat_action` flag.

2
CHANGES/838.misc Normal file
View file

@ -0,0 +1,2 @@
**BREAKING**
Events isolation mechanism is moved from FSM storages to standalone managers

1
CHANGES/839.bugix Normal file
View file

@ -0,0 +1 @@
Fixed I18n lazy-proxy. Disabled caching.

View file

@ -59,7 +59,7 @@ clean:
rm -rf `find . -name .pytest_cache`
rm -rf *.egg-info
rm -f report.html
rm -f .coverage*
rm -f .coverage
rm -rf {build,dist,site,.cache,.mypy_cache,reports}
# =================================================================================================
@ -84,7 +84,7 @@ reformat:
# =================================================================================================
.PHONY: test-run-services
test-run-services:
docker-compose -f tests/docker-compose.yml -p aiogram3-dev up -d
@#docker-compose -f tests/docker-compose.yml -p aiogram3-dev up -d
.PHONY: test
test: test-run-services

View file

@ -13,7 +13,7 @@ aiogram |beta badge|
:target: https://pypi.python.org/pypi/aiogram
:alt: Supported python versions
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.5-blue.svg?logo=telegram
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.7-blue.svg?logo=telegram
:target: https://core.telegram.org/bots/api
:alt: Telegram Bot API

View file

@ -2,6 +2,7 @@ from .client import session
from .client.bot import Bot
from .dispatcher import filters, handler
from .dispatcher.dispatcher import Dispatcher
from .dispatcher.flags.flag import FlagGenerator
from .dispatcher.middlewares.base import BaseMiddleware
from .dispatcher.router import Router
from .utils.magic_filter import MagicFilter
@ -18,6 +19,7 @@ except ImportError: # pragma: no cover
F = MagicFilter()
html = _html_decoration
md = _markdown_decoration
flags = FlagGenerator()
__all__ = (
"__api_version__",
@ -34,7 +36,8 @@ __all__ = (
"F",
"html",
"md",
"flags",
)
__version__ = "3.0.0b1"
__api_version__ = "5.5"
__version__ = "3.0.0b2"
__api_version__ = "5.7"

View file

@ -311,7 +311,9 @@ class Bot(ContextInstanceMixin["Bot"]):
if isinstance(file, str):
file_id = file
else:
file_id = getattr(file, "file_id", None)
# type is ignored in due to:
# Incompatible types in assignment (expression has type "Optional[Any]", variable has type "str")
file_id = getattr(file, "file_id", None) # type: ignore
if file_id is None:
raise TypeError("file can only be of the string or Downloadable type")
@ -533,6 +535,7 @@ class Bot(ContextInstanceMixin["Bot"]):
entities: Optional[List[MessageEntity]] = None,
disable_web_page_preview: Optional[bool] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -551,6 +554,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param entities: A JSON-serialized list of special entities that appear in message text, which can be specified instead of *parse_mode*
:param disable_web_page_preview: Disables link previews for links in this message
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -564,6 +568,7 @@ class Bot(ContextInstanceMixin["Bot"]):
entities=entities,
disable_web_page_preview=disable_web_page_preview,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -576,6 +581,7 @@ class Bot(ContextInstanceMixin["Bot"]):
from_chat_id: Union[int, str],
message_id: int,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
request_timeout: Optional[int] = None,
) -> Message:
"""
@ -587,6 +593,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param from_chat_id: Unique identifier for the chat where the original message was sent (or channel username in the format :code:`@channelusername`)
:param message_id: Message identifier in the chat specified in *from_chat_id*
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the forwarded message from forwarding and saving
:param request_timeout: Request timeout
:return: On success, the sent Message is returned.
"""
@ -595,6 +602,7 @@ class Bot(ContextInstanceMixin["Bot"]):
from_chat_id=from_chat_id,
message_id=message_id,
disable_notification=disable_notification,
protect_content=protect_content,
)
return await self(call, request_timeout=request_timeout)
@ -607,6 +615,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode: Optional[str] = UNSET,
caption_entities: Optional[List[MessageEntity]] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -626,6 +635,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param parse_mode: Mode for parsing entities in the new caption. See `formatting options <https://core.telegram.org/bots/api#formatting-options>`_ for more details.
:param caption_entities: A JSON-serialized list of special entities that appear in the new caption, which can be specified instead of *parse_mode*
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -640,6 +650,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -654,6 +665,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode: Optional[str] = UNSET,
caption_entities: Optional[List[MessageEntity]] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -672,6 +684,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param parse_mode: Mode for parsing entities in the photo caption. See `formatting options <https://core.telegram.org/bots/api#formatting-options>`_ for more details.
:param caption_entities: A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -685,6 +698,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -703,6 +717,7 @@ class Bot(ContextInstanceMixin["Bot"]):
title: Optional[str] = None,
thumb: Optional[Union[InputFile, str]] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -726,6 +741,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param title: Track name
:param thumb: Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side. The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail's width and height should not exceed 320. Ignored if the file is not uploaded using multipart/form-data. Thumbnails can't be reused and can be only uploaded as a new file, so you can pass 'attach://<file_attach_name>' if the thumbnail was uploaded using multipart/form-data under <file_attach_name>. :ref:`More info on Sending Files » <sending-files>`
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -743,6 +759,7 @@ class Bot(ContextInstanceMixin["Bot"]):
title=title,
thumb=thumb,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -759,6 +776,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities: Optional[List[MessageEntity]] = None,
disable_content_type_detection: Optional[bool] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -779,6 +797,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param caption_entities: A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*
:param disable_content_type_detection: Disables automatic server-side content type detection for files uploaded using multipart/form-data
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -794,6 +813,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities=caption_entities,
disable_content_type_detection=disable_content_type_detection,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -813,6 +833,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities: Optional[List[MessageEntity]] = None,
supports_streaming: Optional[bool] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -836,6 +857,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param caption_entities: A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*
:param supports_streaming: Pass :code:`True`, if the uploaded video is suitable for streaming
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -854,6 +876,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities=caption_entities,
supports_streaming=supports_streaming,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -872,6 +895,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode: Optional[str] = UNSET,
caption_entities: Optional[List[MessageEntity]] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -894,6 +918,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param parse_mode: Mode for parsing entities in the animation caption. See `formatting options <https://core.telegram.org/bots/api#formatting-options>`_ for more details.
:param caption_entities: A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -911,6 +936,7 @@ class Bot(ContextInstanceMixin["Bot"]):
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -926,6 +952,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities: Optional[List[MessageEntity]] = None,
duration: Optional[int] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -945,6 +972,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param caption_entities: A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*
:param duration: Duration of the voice message in seconds
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -959,6 +987,7 @@ class Bot(ContextInstanceMixin["Bot"]):
caption_entities=caption_entities,
duration=duration,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -973,6 +1002,7 @@ class Bot(ContextInstanceMixin["Bot"]):
length: Optional[int] = None,
thumb: Optional[Union[InputFile, str]] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -991,6 +1021,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param length: Video width and height, i.e. diameter of the video message
:param thumb: Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side. The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail's width and height should not exceed 320. Ignored if the file is not uploaded using multipart/form-data. Thumbnails can't be reused and can be only uploaded as a new file, so you can pass 'attach://<file_attach_name>' if the thumbnail was uploaded using multipart/form-data under <file_attach_name>. :ref:`More info on Sending Files » <sending-files>`
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -1004,6 +1035,7 @@ class Bot(ContextInstanceMixin["Bot"]):
length=length,
thumb=thumb,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1015,6 +1047,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id: Union[int, str],
media: List[Union[InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo]],
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
request_timeout: Optional[int] = None,
@ -1027,6 +1060,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param chat_id: Unique identifier for the target chat or username of the target channel (in the format :code:`@channelusername`)
:param media: A JSON-serialized array describing messages to be sent, must include 2-10 items
:param disable_notification: Sends messages `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent messages from forwarding and saving
:param reply_to_message_id: If the messages are a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param request_timeout: Request timeout
@ -1036,6 +1070,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id=chat_id,
media=media,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
)
@ -1051,6 +1086,7 @@ class Bot(ContextInstanceMixin["Bot"]):
heading: Optional[int] = None,
proximity_alert_radius: Optional[int] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -1071,6 +1107,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param heading: For live locations, a direction in which the user is moving, in degrees. Must be between 1 and 360 if specified.
:param proximity_alert_radius: For live locations, a maximum distance for proximity alerts about approaching another chat member, in meters. Must be between 1 and 100000 if specified.
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -1086,6 +1123,7 @@ class Bot(ContextInstanceMixin["Bot"]):
heading=heading,
proximity_alert_radius=proximity_alert_radius,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1177,6 +1215,7 @@ class Bot(ContextInstanceMixin["Bot"]):
google_place_id: Optional[str] = None,
google_place_type: Optional[str] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -1199,6 +1238,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param google_place_id: Google Places identifier of the venue
:param google_place_type: Google Places type of the venue. (See `supported types <https://developers.google.com/places/web-service/supported_types>`_.)
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -1216,6 +1256,7 @@ class Bot(ContextInstanceMixin["Bot"]):
google_place_id=google_place_id,
google_place_type=google_place_type,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1230,6 +1271,7 @@ class Bot(ContextInstanceMixin["Bot"]):
last_name: Optional[str] = None,
vcard: Optional[str] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -1248,6 +1290,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param last_name: Contact's last name
:param vcard: Additional data about the contact in the form of a `vCard <https://en.wikipedia.org/wiki/VCard>`_, 0-2048 bytes
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove keyboard or to force a reply from the user.
@ -1261,6 +1304,7 @@ class Bot(ContextInstanceMixin["Bot"]):
last_name=last_name,
vcard=vcard,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1283,6 +1327,7 @@ class Bot(ContextInstanceMixin["Bot"]):
close_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None,
is_closed: Optional[bool] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -1309,6 +1354,7 @@ class Bot(ContextInstanceMixin["Bot"]):
: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*.
:param is_closed: Pass :code:`True`, if the poll needs to be immediately closed. This can be useful for poll preview.
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -1330,6 +1376,7 @@ class Bot(ContextInstanceMixin["Bot"]):
close_date=close_date,
is_closed=is_closed,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1341,6 +1388,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id: Union[int, str],
emoji: Optional[str] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -1356,6 +1404,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param chat_id: Unique identifier for the target chat or username of the target channel (in the format :code:`@channelusername`)
:param emoji: Emoji on which the dice throw animation is based. Currently, must be one of '🎲', '🎯', '🏀', '', '🎳', or '🎰'. Dice can have values 1-6 for '🎲', '🎯' and '🎳', values 1-5 for '🏀' and '', and values 1-64 for '🎰'. Defaults to '🎲'
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -1366,6 +1415,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id=chat_id,
emoji=emoji,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -1517,7 +1567,7 @@ class Bot(ContextInstanceMixin["Bot"]):
Source: https://core.telegram.org/bots/api#unbanchatmember
:param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@username`)
:param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`)
:param user_id: Unique identifier of the target user
:param only_if_banned: Do nothing if the user is not banned
:param request_timeout: Request timeout
@ -2512,6 +2562,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id: Union[int, str],
sticker: Union[InputFile, str],
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[
@ -2520,13 +2571,14 @@ class Bot(ContextInstanceMixin["Bot"]):
request_timeout: Optional[int] = None,
) -> Message:
"""
Use this method to send static .WEBP or `animated <https://telegram.org/blog/animated-stickers>`_ .TGS stickers. On success, the sent :class:`aiogram.types.message.Message` is returned.
Use this method to send static .WEBP, `animated <https://telegram.org/blog/animated-stickers>`_ .TGS, or `video <https://telegram.org/blog/video-stickers-better-reactions>`_ .WEBM stickers. On success, the sent :class:`aiogram.types.message.Message` is returned.
Source: https://core.telegram.org/bots/api#sendsticker
:param chat_id: Unique identifier for the target chat or username of the target channel (in the format :code:`@channelusername`)
:param sticker: Sticker to send. Pass a file_id as String to send a file that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get a .WEBP file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: Additional interface options. A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_, `custom reply keyboard <https://core.telegram.org/bots#keyboards>`_, instructions to remove reply keyboard or to force a reply from the user.
@ -2537,6 +2589,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id=chat_id,
sticker=sticker,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -2592,12 +2645,13 @@ class Bot(ContextInstanceMixin["Bot"]):
emojis: str,
png_sticker: Optional[Union[InputFile, str]] = None,
tgs_sticker: Optional[InputFile] = None,
webm_sticker: Optional[InputFile] = None,
contains_masks: Optional[bool] = None,
mask_position: Optional[MaskPosition] = None,
request_timeout: Optional[int] = None,
) -> bool:
"""
Use this method to create a new sticker set owned by a user. The bot will be able to edit the sticker set thus created. You **must** use exactly one of the fields *png_sticker* or *tgs_sticker*. Returns :code:`True` on success.
Use this method to create a new sticker set owned by a user. The bot will be able to edit the sticker set thus created. You **must** use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker*. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#createnewstickerset
@ -2606,7 +2660,8 @@ class Bot(ContextInstanceMixin["Bot"]):
:param title: Sticker set title, 1-64 characters
:param emojis: One or more emoji corresponding to the sticker
:param png_sticker: **PNG** image with the sticker, must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`
:param tgs_sticker: **TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for technical requirements
:param tgs_sticker: **TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for technical requirements
:param webm_sticker: **WEBM** video with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for technical requirements
:param contains_masks: Pass :code:`True`, if a set of mask stickers should be created
:param mask_position: A JSON-serialized object for position where the mask should be placed on faces
:param request_timeout: Request timeout
@ -2619,6 +2674,7 @@ class Bot(ContextInstanceMixin["Bot"]):
emojis=emojis,
png_sticker=png_sticker,
tgs_sticker=tgs_sticker,
webm_sticker=webm_sticker,
contains_masks=contains_masks,
mask_position=mask_position,
)
@ -2631,11 +2687,12 @@ class Bot(ContextInstanceMixin["Bot"]):
emojis: str,
png_sticker: Optional[Union[InputFile, str]] = None,
tgs_sticker: Optional[InputFile] = None,
webm_sticker: Optional[InputFile] = None,
mask_position: Optional[MaskPosition] = None,
request_timeout: Optional[int] = None,
) -> bool:
"""
Use this method to add a new sticker to a set created by the bot. You **must** use exactly one of the fields *png_sticker* or *tgs_sticker*. Animated stickers can be added to animated sticker sets and only to them. Animated sticker sets can have up to 50 stickers. Static sticker sets can have up to 120 stickers. Returns :code:`True` on success.
Use this method to add a new sticker to a set created by the bot. You **must** use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker*. Animated stickers can be added to animated sticker sets and only to them. Animated sticker sets can have up to 50 stickers. Static sticker sets can have up to 120 stickers. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#addstickertoset
@ -2643,7 +2700,8 @@ class Bot(ContextInstanceMixin["Bot"]):
:param name: Sticker set name
:param emojis: One or more emoji corresponding to the sticker
:param png_sticker: **PNG** image with the sticker, must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`
:param tgs_sticker: **TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for technical requirements
:param tgs_sticker: **TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for technical requirements
:param webm_sticker: **WEBM** video with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for technical requirements
:param mask_position: A JSON-serialized object for position where the mask should be placed on faces
:param request_timeout: Request timeout
:return: Returns True on success.
@ -2654,6 +2712,7 @@ class Bot(ContextInstanceMixin["Bot"]):
emojis=emojis,
png_sticker=png_sticker,
tgs_sticker=tgs_sticker,
webm_sticker=webm_sticker,
mask_position=mask_position,
)
return await self(call, request_timeout=request_timeout)
@ -2707,13 +2766,13 @@ class Bot(ContextInstanceMixin["Bot"]):
request_timeout: Optional[int] = None,
) -> bool:
"""
Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set for animated sticker sets only. Returns :code:`True` on success.
Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set for animated sticker sets only. Video thumbnails can be set only for video sticker sets only. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#setstickersetthumb
:param name: Sticker set name
:param user_id: User identifier of the sticker set owner
:param thumb: A **PNG** image with the thumbnail, must be up to 128 kilobytes in size and have width and height exactly 100px, or a **TGS** animation with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for animated sticker technical requirements. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`. Animated sticker set thumbnail can't be uploaded via HTTP URL.
:param thumb: A **PNG** image with the thumbnail, must be up to 128 kilobytes in size and have width and height exactly 100px, or a **TGS** animation with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for animated sticker technical requirements, or a **WEBM** video with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for video sticker technical requirements. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`. Animated sticker set thumbnails can't be uploaded via HTTP URL.
:param request_timeout: Request timeout
:return: Returns True on success.
"""
@ -2798,6 +2857,7 @@ class Bot(ContextInstanceMixin["Bot"]):
send_email_to_provider: Optional[bool] = None,
is_flexible: Optional[bool] = None,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[InlineKeyboardMarkup] = None,
@ -2831,6 +2891,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param send_email_to_provider: Pass :code:`True`, if user's email address should be sent to provider
:param is_flexible: Pass :code:`True`, if the final price depends on the shipping method
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_. If empty, one 'Pay :code:`total price`' button will be shown. If not empty, the first button must be a Pay button.
@ -2861,6 +2922,7 @@ class Bot(ContextInstanceMixin["Bot"]):
send_email_to_provider=send_email_to_provider,
is_flexible=is_flexible,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,
@ -2960,6 +3022,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id: int,
game_short_name: str,
disable_notification: Optional[bool] = None,
protect_content: Optional[bool] = None,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: Optional[bool] = None,
reply_markup: Optional[InlineKeyboardMarkup] = None,
@ -2973,6 +3036,7 @@ class Bot(ContextInstanceMixin["Bot"]):
:param chat_id: Unique identifier for the target chat
:param game_short_name: Short name of the game, serves as the unique identifier for the game. Set up your games via `Botfather <https://t.me/botfather>`_.
:param disable_notification: Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound.
:param protect_content: Protects the contents of the sent message from forwarding and saving
:param reply_to_message_id: If the message is a reply, ID of the original message
:param allow_sending_without_reply: Pass :code:`True`, if the message should be sent even if the specified replied-to message is not found
:param reply_markup: A JSON-serialized object for an `inline keyboard <https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating>`_. If empty, one 'Play game_title' button will be shown. If not empty, the first button must launch the game.
@ -2983,6 +3047,7 @@ class Bot(ContextInstanceMixin["Bot"]):
chat_id=chat_id,
game_short_name=game_short_name,
disable_notification=disable_notification,
protect_content=protect_content,
reply_to_message_id=reply_to_message_id,
allow_sending_without_reply=allow_sending_without_reply,
reply_markup=reply_markup,

View file

@ -16,8 +16,8 @@ from ..utils.backoff import Backoff, BackoffConfig
from .event.bases import UNHANDLED, SkipHandler
from .event.telegram import TelegramEventObserver
from .fsm.middleware import FSMContextMiddleware
from .fsm.storage.base import BaseStorage
from .fsm.storage.memory import MemoryStorage
from .fsm.storage.base import BaseEventIsolation, BaseStorage
from .fsm.storage.memory import DisabledEventIsolation, MemoryStorage
from .fsm.strategy import FSMStrategy
from .middlewares.error import ErrorsMiddleware
from .middlewares.user_context import UserContextMiddleware
@ -35,7 +35,7 @@ class Dispatcher(Router):
self,
storage: Optional[BaseStorage] = None,
fsm_strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT,
isolate_events: bool = False,
events_isolation: Optional[BaseEventIsolation] = None,
**kwargs: Any,
) -> None:
super(Dispatcher, self).__init__(**kwargs)
@ -48,19 +48,22 @@ class Dispatcher(Router):
)
self.update.register(self._listen_update)
# Error handlers should works is out of all other functions and be registered before all other middlewares
# Error handlers should work is out of all other functions and be registered before all others middlewares
self.update.outer_middleware(ErrorsMiddleware(self))
# User context middleware makes small optimization for all other builtin
# middlewares via caching the user and chat instances in the event context
self.update.outer_middleware(UserContextMiddleware())
# FSM middleware should always be registered after User context middleware
# because here is used context from previous step
self.fsm = FSMContextMiddleware(
storage=storage if storage else MemoryStorage(),
strategy=fsm_strategy,
isolate_events=isolate_events,
events_isolation=events_isolation if events_isolation else DisabledEventIsolation(),
)
self.update.outer_middleware(self.fsm)
self.shutdown.register(self.fsm.close)
self._running_lock = Lock()
@ -104,7 +107,7 @@ class Dispatcher(Router):
finally:
finish_time = loop.time()
duration = (finish_time - start_time) * 1000
loggers.dispatcher.info(
loggers.event.info(
"Update id=%s is %s. Duration %d ms by bot id=%d",
update.update_id,
"handled" if handled else "not handled",
@ -213,11 +216,11 @@ class Dispatcher(Router):
try:
await bot(result)
except TelegramAPIError as e:
# In due to WebHook mechanism doesn't allows to get response for
# In due to WebHook mechanism doesn't allow getting response for
# requests called in answer to WebHook request.
# Need to skip unsuccessful responses.
# For debugging here is added logging.
loggers.dispatcher.error("Failed to make answer: %s: %s", e.__class__.__name__, e)
loggers.event.error("Failed to make answer: %s: %s", e.__class__.__name__, e)
async def _process_update(
self, bot: Bot, update: Update, call_answer: bool = True, **kwargs: Any
@ -238,7 +241,7 @@ class Dispatcher(Router):
return response is not UNHANDLED
except Exception as e:
loggers.dispatcher.exception(
loggers.event.exception(
"Cause exception while process update id=%d by bot id=%d\n%s: %s",
update.update_id,
bot.id,
@ -282,7 +285,7 @@ class Dispatcher(Router):
try:
return await self.feed_update(bot, update, **kwargs)
except Exception as e:
loggers.dispatcher.exception(
loggers.event.exception(
"Cause exception while process update id=%d by bot id=%d\n%s: %s",
update.update_id,
bot.id,

View file

@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Type,
from magic_filter import MagicFilter
from aiogram.dispatcher.filters.base import BaseFilter
from aiogram.dispatcher.flags.getter import extract_flags_from_object
from aiogram.dispatcher.handler.base import BaseHandler
CallbackType = Callable[..., Awaitable[Any]]
@ -71,6 +72,7 @@ class HandlerObject(CallableMixin):
callback = inspect.unwrap(self.callback)
if inspect.isclass(callback) and issubclass(callback, BaseHandler):
self.awaitable = True
self.flags.update(extract_flags_from_object(callback))
async def check(self, *args: Any, **kwargs: Any) -> Tuple[bool, Dict[str, Any]]:
if not self.filters:

View file

@ -1,6 +1,21 @@
from typing import Dict, Tuple, Type
from .base import BaseFilter
from .chat_member_updated import (
ADMINISTRATOR,
CREATOR,
IS_ADMIN,
IS_MEMBER,
IS_NOT_MEMBER,
JOIN_TRANSITION,
KICKED,
LEAVE_TRANSITION,
LEFT,
MEMBER,
PROMOTED_TRANSITION,
RESTRICTED,
ChatMemberUpdatedFilter,
)
from .command import Command, CommandObject
from .content_types import ContentTypesFilter
from .exception import ExceptionMessageFilter, ExceptionTypeFilter
@ -19,6 +34,19 @@ __all__ = (
"ExceptionTypeFilter",
"StateFilter",
"MagicData",
"ChatMemberUpdatedFilter",
"CREATOR",
"ADMINISTRATOR",
"MEMBER",
"RESTRICTED",
"LEFT",
"KICKED",
"IS_MEMBER",
"IS_ADMIN",
"PROMOTED_TRANSITION",
"IS_NOT_MEMBER",
"JOIN_TRANSITION",
"LEAVE_TRANSITION",
)
_ALL_EVENTS_FILTERS: Tuple[Type[BaseFilter], ...] = (MagicData,)
@ -84,10 +112,12 @@ BUILTIN_FILTERS: Dict[str, Tuple[Type[BaseFilter], ...]] = {
"my_chat_member": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
ChatMemberUpdatedFilter,
),
"chat_member": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
ChatMemberUpdatedFilter,
),
"chat_join_request": (
*_ALL_EVENTS_FILTERS,

View file

@ -0,0 +1,179 @@
from typing import Any, Dict, Optional, TypeVar, Union
from aiogram.dispatcher.filters import BaseFilter
from aiogram.types import ChatMember, ChatMemberUpdated
MarkerT = TypeVar("MarkerT", bound="_MemberStatusMarker")
MarkerGroupT = TypeVar("MarkerGroupT", bound="_MemberStatusGroupMarker")
TransitionT = TypeVar("TransitionT", bound="_MemberStatusTransition")
class _MemberStatusMarker:
def __init__(self, name: str, *, is_member: Optional[bool] = None) -> None:
self.name = name
self.is_member = is_member
def __str__(self) -> str:
result = self.name.upper()
if self.is_member is not None:
result = ("+" if self.is_member else "-") + result
return result
def __pos__(self: MarkerT) -> MarkerT:
return type(self)(name=self.name, is_member=True)
def __neg__(self: MarkerT) -> MarkerT:
return type(self)(name=self.name, is_member=False)
def __or__(
self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> "_MemberStatusGroupMarker":
if isinstance(other, _MemberStatusMarker):
return _MemberStatusGroupMarker(self, other)
if isinstance(other, _MemberStatusGroupMarker):
return other | self
raise TypeError(
f"unsupported operand type(s) for |: {type(self).__name__!r} and {type(other).__name__!r}"
)
__ror__ = __or__
def __rshift__(
self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> "_MemberStatusTransition":
old = _MemberStatusGroupMarker(self)
if isinstance(other, _MemberStatusMarker):
return _MemberStatusTransition(old=old, new=_MemberStatusGroupMarker(other))
if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=old, new=other)
raise TypeError(
f"unsupported operand type(s) for >>: {type(self).__name__!r} and {type(other).__name__!r}"
)
def __lshift__(
self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> "_MemberStatusTransition":
new = _MemberStatusGroupMarker(self)
if isinstance(other, _MemberStatusMarker):
return _MemberStatusTransition(old=_MemberStatusGroupMarker(other), new=new)
if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=other, new=new)
raise TypeError(
f"unsupported operand type(s) for <<: {type(self).__name__!r} and {type(other).__name__!r}"
)
def __hash__(self) -> int:
return hash((self.name, self.is_member))
def check(self, *, member: ChatMember) -> bool:
if self.is_member is not None and member.is_member != self.is_member:
return False
return self.name == member.status
class _MemberStatusGroupMarker:
def __init__(self, *statuses: _MemberStatusMarker) -> None:
if not statuses:
raise ValueError("Member status group should have at least one status included")
self.statuses = frozenset(statuses)
def __or__(
self: MarkerGroupT, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> MarkerGroupT:
if isinstance(other, _MemberStatusMarker):
return type(self)(*self.statuses, other)
elif isinstance(other, _MemberStatusGroupMarker):
return type(self)(*self.statuses, *other.statuses)
raise TypeError(
f"unsupported operand type(s) for |: {type(self).__name__!r} and {type(other).__name__!r}"
)
def __rshift__(
self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> "_MemberStatusTransition":
if isinstance(other, _MemberStatusMarker):
return _MemberStatusTransition(old=self, new=_MemberStatusGroupMarker(other))
if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=self, new=other)
raise TypeError(
f"unsupported operand type(s) for >>: {type(self).__name__!r} and {type(other).__name__!r}"
)
def __lshift__(
self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"]
) -> "_MemberStatusTransition":
if isinstance(other, _MemberStatusMarker):
return _MemberStatusTransition(old=_MemberStatusGroupMarker(other), new=self)
if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=other, new=self)
raise TypeError(
f"unsupported operand type(s) for <<: {type(self).__name__!r} and {type(other).__name__!r}"
)
def __str__(self) -> str:
result = " | ".join(map(str, sorted(self.statuses, key=str)))
if len(self.statuses) != 1:
return f"({result})"
return result
def check(self, *, member: ChatMember) -> bool:
for status in self.statuses:
if status.check(member=member):
return True
return False
class _MemberStatusTransition:
def __init__(self, *, old: _MemberStatusGroupMarker, new: _MemberStatusGroupMarker) -> None:
self.old = old
self.new = new
def __str__(self) -> str:
return f"{self.old} >> {self.new}"
def __invert__(self: TransitionT) -> TransitionT:
return type(self)(old=self.new, new=self.old)
def check(self, *, old: ChatMember, new: ChatMember) -> bool:
return self.old.check(member=old) and self.new.check(member=new)
CREATOR = _MemberStatusMarker("creator")
ADMINISTRATOR = _MemberStatusMarker("administrator")
MEMBER = _MemberStatusMarker("member")
RESTRICTED = _MemberStatusMarker("restricted")
LEFT = _MemberStatusMarker("left")
KICKED = _MemberStatusMarker("kicked")
IS_MEMBER = CREATOR | ADMINISTRATOR | MEMBER | +RESTRICTED
IS_ADMIN = CREATOR | ADMINISTRATOR
IS_NOT_MEMBER = LEFT | KICKED | -RESTRICTED
JOIN_TRANSITION = IS_NOT_MEMBER >> IS_MEMBER
LEAVE_TRANSITION = ~JOIN_TRANSITION
PROMOTED_TRANSITION = (MEMBER | RESTRICTED | LEFT | KICKED) >> ADMINISTRATOR
class ChatMemberUpdatedFilter(BaseFilter):
member_status_changed: Union[
_MemberStatusMarker,
_MemberStatusGroupMarker,
_MemberStatusTransition,
]
"""Accepts the status transition or new status of the member (see usage in docs)"""
class Config:
arbitrary_types_allowed = True
async def __call__(self, member_updated: ChatMemberUpdated) -> Union[bool, Dict[str, Any]]:
old = member_updated.old_chat_member
new = member_updated.new_chat_member
rule = self.member_status_changed
if isinstance(rule, (_MemberStatusMarker, _MemberStatusGroupMarker)):
return rule.check(member=new)
if isinstance(rule, _MemberStatusTransition):
return rule.check(old=old, new=new)
# Impossible variant in due to pydantic validation
return False # pragma: no cover

View file

View file

@ -0,0 +1,60 @@
from dataclasses import dataclass
from typing import Any, Callable, Optional, Union, cast, overload
from magic_filter import AttrDict
from aiogram.dispatcher.flags.getter import extract_flags_from_object
@dataclass(frozen=True)
class Flag:
name: str
value: Any
@dataclass(frozen=True)
class FlagDecorator:
flag: Flag
@classmethod
def _with_flag(cls, flag: Flag) -> "FlagDecorator":
return cls(flag)
def _with_value(self, value: Any) -> "FlagDecorator":
new_flag = Flag(self.flag.name, value)
return self._with_flag(new_flag)
@overload
def __call__(self, value: Callable[..., Any]) -> Callable[..., Any]: # type: ignore
pass
@overload
def __call__(self, value: Any) -> "FlagDecorator":
pass
@overload
def __call__(self, **kwargs: Any) -> "FlagDecorator":
pass
def __call__(
self,
value: Optional[Any] = None,
**kwargs: Any,
) -> Union[Callable[..., Any], "FlagDecorator"]:
if value and kwargs:
raise ValueError("The arguments `value` and **kwargs can not be used together")
if value is not None and callable(value):
value.aiogram_flag = {
**extract_flags_from_object(value),
self.flag.name: self.flag.value,
}
return cast(Callable[..., Any], value)
return self._with_value(AttrDict(kwargs) if value is None else value)
class FlagGenerator:
def __getattr__(self, name: str) -> FlagDecorator:
if name[0] == "_":
raise AttributeError("Flag name must NOT start with underscore")
return FlagDecorator(Flag(name, True))

View file

@ -0,0 +1,56 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast
from magic_filter import AttrDict, MagicFilter
if TYPE_CHECKING:
from aiogram.dispatcher.event.handler import HandlerObject
def extract_flags_from_object(obj: Any) -> Dict[str, Any]:
if not hasattr(obj, "aiogram_flag"):
return {}
return cast(Dict[str, Any], obj.aiogram_flag)
def extract_flags(handler: Union["HandlerObject", Dict[str, Any]]) -> Dict[str, Any]:
"""
Extract flags from handler or middleware context data
:param handler: handler object or data
:return: dictionary with all handler flags
"""
if isinstance(handler, dict) and "handler" in handler:
handler = handler["handler"]
if not hasattr(handler, "flags"):
return {}
return handler.flags # type: ignore
def get_flag(
handler: Union["HandlerObject", Dict[str, Any]],
name: str,
*,
default: Optional[Any] = None,
) -> Any:
"""
Get flag by name
:param handler: handler object or data
:param name: name of the flag
:param default: default value (None)
:return: value of the flag or default
"""
flags = extract_flags(handler)
return flags.get(name, default)
def check_flags(handler: Union["HandlerObject", Dict[str, Any]], magic: MagicFilter) -> Any:
"""
Check flags via magic filter
:param handler: handler object or data
:param magic: instance of the magic
:return: the result of magic filter check
"""
flags = extract_flags(handler)
return magic.resolve(AttrDict(flags))

View file

@ -2,7 +2,12 @@ from typing import Any, Awaitable, Callable, Dict, Optional, cast
from aiogram import Bot
from aiogram.dispatcher.fsm.context import FSMContext
from aiogram.dispatcher.fsm.storage.base import DEFAULT_DESTINY, BaseStorage, StorageKey
from aiogram.dispatcher.fsm.storage.base import (
DEFAULT_DESTINY,
BaseEventIsolation,
BaseStorage,
StorageKey,
)
from aiogram.dispatcher.fsm.strategy import FSMStrategy, apply_strategy
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from aiogram.types import TelegramObject
@ -12,12 +17,12 @@ class FSMContextMiddleware(BaseMiddleware):
def __init__(
self,
storage: BaseStorage,
events_isolation: BaseEventIsolation,
strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT,
isolate_events: bool = True,
) -> None:
self.storage = storage
self.strategy = strategy
self.isolate_events = isolate_events
self.events_isolation = events_isolation
async def __call__(
self,
@ -30,9 +35,8 @@ class FSMContextMiddleware(BaseMiddleware):
data["fsm_storage"] = self.storage
if context:
data.update({"state": context, "raw_state": await context.get_state()})
if self.isolate_events:
async with self.storage.lock(bot=bot, key=context.key):
return await handler(event, data)
async with self.events_isolation.lock(bot=bot, key=context.key):
return await handler(event, data)
return await handler(event, data)
def resolve_event_context(
@ -81,3 +85,7 @@ class FSMContextMiddleware(BaseMiddleware):
destiny=destiny,
),
)
async def close(self) -> None:
await self.storage.close()
await self.events_isolation.close()

View file

@ -24,19 +24,6 @@ class BaseStorage(ABC):
Base class for all FSM storages
"""
@abstractmethod
@asynccontextmanager
async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]:
"""
Isolate events with lock.
Will be used as context manager
:param bot: instance of the current bot
:param key: storage key
:return: An async generator
"""
yield None
@abstractmethod
async def set_state(self, bot: Bot, key: StorageKey, state: StateType = None) -> None:
"""
@ -101,3 +88,22 @@ class BaseStorage(ABC):
Close storage (database connection, file or etc.)
"""
pass
class BaseEventIsolation(ABC):
@abstractmethod
@asynccontextmanager
async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]:
"""
Isolate events with lock.
Will be used as context manager
:param bot: instance of the current bot
:param key: storage key
:return: An async generator
"""
yield None
@abstractmethod
async def close(self) -> None:
pass

View file

@ -2,18 +2,22 @@ from asyncio import Lock
from collections import defaultdict
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, DefaultDict, Dict, Optional
from typing import Any, AsyncGenerator, DefaultDict, Dict, Hashable, Optional
from aiogram import Bot
from aiogram.dispatcher.fsm.state import State
from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType, StorageKey
from aiogram.dispatcher.fsm.storage.base import (
BaseEventIsolation,
BaseStorage,
StateType,
StorageKey,
)
@dataclass
class MemoryStorageRecord:
data: Dict[str, Any] = field(default_factory=dict)
state: Optional[str] = None
lock: Lock = field(default_factory=Lock)
class MemoryStorage(BaseStorage):
@ -34,11 +38,6 @@ class MemoryStorage(BaseStorage):
async def close(self) -> None:
pass
@asynccontextmanager
async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]:
async with self.storage[key].lock:
yield None
async def set_state(self, bot: Bot, key: StorageKey, state: StateType = None) -> None:
self.storage[key].state = state.state if isinstance(state, State) else state
@ -50,3 +49,27 @@ class MemoryStorage(BaseStorage):
async def get_data(self, bot: Bot, key: StorageKey) -> Dict[str, Any]:
return self.storage[key].data.copy()
class DisabledEventIsolation(BaseEventIsolation):
@asynccontextmanager
async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]:
yield
async def close(self) -> None:
pass
class SimpleEventIsolation(BaseEventIsolation):
def __init__(self) -> None:
# TODO: Unused locks cleaner is needed
self._locks: DefaultDict[Hashable, Lock] = defaultdict(Lock)
@asynccontextmanager
async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]:
lock = self._locks[key]
async with lock:
yield
async def close(self) -> None:
self._locks.clear()

View file

@ -6,7 +6,13 @@ from aioredis import ConnectionPool, Redis
from aiogram import Bot
from aiogram.dispatcher.fsm.state import State
from aiogram.dispatcher.fsm.storage.base import DEFAULT_DESTINY, BaseStorage, StateType, StorageKey
from aiogram.dispatcher.fsm.storage.base import (
DEFAULT_DESTINY,
BaseEventIsolation,
BaseStorage,
StateType,
StorageKey,
)
DEFAULT_REDIS_LOCK_KWARGS = {"timeout": 60}
@ -121,19 +127,12 @@ class RedisStorage(BaseStorage):
redis = Redis(connection_pool=pool)
return cls(redis=redis, **kwargs)
def create_isolation(self, **kwargs: Any) -> "RedisEventIsolation":
return RedisEventIsolation(redis=self.redis, key_builder=self.key_builder, **kwargs)
async def close(self) -> None:
await self.redis.close() # type: ignore
@asynccontextmanager
async def lock(
self,
bot: Bot,
key: StorageKey,
) -> AsyncGenerator[None, None]:
redis_key = self.key_builder.build(key, "lock")
async with self.redis.lock(name=redis_key, **self.lock_kwargs):
yield None
async def set_state(
self,
bot: Bot,
@ -146,8 +145,8 @@ class RedisStorage(BaseStorage):
else:
await self.redis.set(
redis_key,
state.state if isinstance(state, State) else state, # type: ignore[arg-type]
ex=self.state_ttl, # type: ignore[arg-type]
cast(str, state.state if isinstance(state, State) else state),
ex=self.state_ttl,
)
async def get_state(
@ -174,7 +173,7 @@ class RedisStorage(BaseStorage):
await self.redis.set(
redis_key,
bot.session.json_dumps(data),
ex=self.data_ttl, # type: ignore[arg-type]
ex=self.data_ttl,
)
async def get_data(
@ -189,3 +188,43 @@ class RedisStorage(BaseStorage):
if isinstance(value, bytes):
value = value.decode("utf-8")
return cast(Dict[str, Any], bot.session.json_loads(value))
class RedisEventIsolation(BaseEventIsolation):
def __init__(
self,
redis: Redis,
key_builder: Optional[KeyBuilder] = None,
lock_kwargs: Optional[Dict[str, Any]] = None,
) -> None:
if key_builder is None:
key_builder = DefaultKeyBuilder()
self.redis = redis
self.key_builder = key_builder
self.lock_kwargs = lock_kwargs or {}
@classmethod
def from_url(
cls,
url: str,
connection_kwargs: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> "RedisEventIsolation":
if connection_kwargs is None:
connection_kwargs = {}
pool = ConnectionPool.from_url(url, **connection_kwargs)
redis = Redis(connection_pool=pool)
return cls(redis=redis, **kwargs)
@asynccontextmanager
async def lock(
self,
bot: Bot,
key: StorageKey,
) -> AsyncGenerator[None, None]:
redis_key = self.key_builder.build(key, "lock")
async with self.redis.lock(name=redis_key, **self.lock_kwargs):
yield None
async def close(self) -> None:
pass

View file

@ -134,7 +134,7 @@ class BaseRequestHandler(ABC):
bot=bot, update=await request.json(loads=bot.session.json_loads)
)
)
return web.json_response({})
return web.json_response({}, dumps=bot.session.json_dumps)
async def _handle_request(self, bot: Bot, request: web.Request) -> web.Response:
result = await self.dispatcher.feed_webhook_update(
@ -143,8 +143,8 @@ class BaseRequestHandler(ABC):
**self.data,
)
if result:
return web.json_response(result)
return web.json_response({})
return web.json_response(result, dumps=bot.session.json_dumps)
return web.json_response({}, dumps=bot.session.json_dumps)
async def handle(self, request: web.Request) -> web.Response:
bot = await self.resolve_bot(request)

View file

@ -1,5 +1,6 @@
import logging
dispatcher = logging.getLogger("aiogram.dispatcher")
event = logging.getLogger("aiogram.event")
middlewares = logging.getLogger("aiogram.middlewares")
webhook = logging.getLogger("aiogram.webhook")

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class AddStickerToSet(TelegramMethod[bool]):
"""
Use this method to add a new sticker to a set created by the bot. You **must** use exactly one of the fields *png_sticker* or *tgs_sticker*. Animated stickers can be added to animated sticker sets and only to them. Animated sticker sets can have up to 50 stickers. Static sticker sets can have up to 120 stickers. Returns :code:`True` on success.
Use this method to add a new sticker to a set created by the bot. You **must** use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker*. Animated stickers can be added to animated sticker sets and only to them. Animated sticker sets can have up to 50 stickers. Static sticker sets can have up to 120 stickers. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#addstickertoset
"""
@ -27,15 +27,18 @@ class AddStickerToSet(TelegramMethod[bool]):
png_sticker: Optional[Union[InputFile, str]] = None
"""**PNG** image with the sticker, must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`"""
tgs_sticker: Optional[InputFile] = None
"""**TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for technical requirements"""
"""**TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for technical requirements"""
webm_sticker: Optional[InputFile] = None
"""**WEBM** video with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for technical requirements"""
mask_position: Optional[MaskPosition] = None
"""A JSON-serialized object for position where the mask should be placed on faces"""
def build_request(self, bot: Bot) -> Request:
data: Dict[str, Any] = self.dict(exclude={"png_sticker", "tgs_sticker"})
data: Dict[str, Any] = self.dict(exclude={"png_sticker", "tgs_sticker", "webm_sticker"})
files: Dict[str, InputFile] = {}
prepare_file(data=data, files=files, name="png_sticker", value=self.png_sticker)
prepare_file(data=data, files=files, name="tgs_sticker", value=self.tgs_sticker)
prepare_file(data=data, files=files, name="webm_sticker", value=self.webm_sticker)
return Request(method="addStickerToSet", data=data, files=files)

View file

@ -40,6 +40,8 @@ class CopyMessage(TelegramMethod[MessageId]):
"""A JSON-serialized list of special entities that appear in the new caption, which can be specified instead of *parse_mode*"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class CreateNewStickerSet(TelegramMethod[bool]):
"""
Use this method to create a new sticker set owned by a user. The bot will be able to edit the sticker set thus created. You **must** use exactly one of the fields *png_sticker* or *tgs_sticker*. Returns :code:`True` on success.
Use this method to create a new sticker set owned by a user. The bot will be able to edit the sticker set thus created. You **must** use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker*. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#createnewstickerset
"""
@ -29,17 +29,20 @@ class CreateNewStickerSet(TelegramMethod[bool]):
png_sticker: Optional[Union[InputFile, str]] = None
"""**PNG** image with the sticker, must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`"""
tgs_sticker: Optional[InputFile] = None
"""**TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for technical requirements"""
"""**TGS** animation with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for technical requirements"""
webm_sticker: Optional[InputFile] = None
"""**WEBM** video with the sticker, uploaded using multipart/form-data. See `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for technical requirements"""
contains_masks: Optional[bool] = None
"""Pass :code:`True`, if a set of mask stickers should be created"""
mask_position: Optional[MaskPosition] = None
"""A JSON-serialized object for position where the mask should be placed on faces"""
def build_request(self, bot: Bot) -> Request:
data: Dict[str, Any] = self.dict(exclude={"png_sticker", "tgs_sticker"})
data: Dict[str, Any] = self.dict(exclude={"png_sticker", "tgs_sticker", "webm_sticker"})
files: Dict[str, InputFile] = {}
prepare_file(data=data, files=files, name="png_sticker", value=self.png_sticker)
prepare_file(data=data, files=files, name="tgs_sticker", value=self.tgs_sticker)
prepare_file(data=data, files=files, name="webm_sticker", value=self.webm_sticker)
return Request(method="createNewStickerSet", data=data, files=files)

View file

@ -26,6 +26,8 @@ class ForwardMessage(TelegramMethod[Message]):
"""Message identifier in the chat specified in *from_chat_id*"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the forwarded message from forwarding and saving"""
def build_request(self, bot: Bot) -> Request:
data: Dict[str, Any] = self.dict()

View file

@ -47,6 +47,8 @@ class SendAnimation(TelegramMethod[Message]):
"""A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -48,6 +48,8 @@ class SendAudio(TelegramMethod[Message]):
"""Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side. The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail's width and height should not exceed 320. Ignored if the file is not uploaded using multipart/form-data. Thumbnails can't be reused and can be only uploaded as a new file, so you can pass 'attach://<file_attach_name>' if the thumbnail was uploaded using multipart/form-data under <file_attach_name>. :ref:`More info on Sending Files » <sending-files>`"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -36,6 +36,8 @@ class SendContact(TelegramMethod[Message]):
"""Additional data about the contact in the form of a `vCard <https://en.wikipedia.org/wiki/VCard>`_, 0-2048 bytes"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -30,6 +30,8 @@ class SendDice(TelegramMethod[Message]):
"""Emoji on which the dice throw animation is based. Currently, must be one of '🎲', '🎯', '🏀', '', '🎳', or '🎰'. Dice can have values 1-6 for '🎲', '🎯' and '🎳', values 1-5 for '🏀' and '', and values 1-64 for '🎰'. Defaults to '🎲'"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -43,6 +43,8 @@ class SendDocument(TelegramMethod[Message]):
"""Disables automatic server-side content type detection for files uploaded using multipart/form-data"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -24,6 +24,8 @@ class SendGame(TelegramMethod[Message]):
"""Short name of the game, serves as the unique identifier for the game. Set up your games via `Botfather <https://t.me/botfather>`_."""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -64,6 +64,8 @@ class SendInvoice(TelegramMethod[Message]):
"""Pass :code:`True`, if the final price depends on the shipping method"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -40,6 +40,8 @@ class SendLocation(TelegramMethod[Message]):
"""For live locations, a maximum distance for proximity alerts about approaching another chat member, in meters. Must be between 1 and 100000 if specified."""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -31,6 +31,8 @@ class SendMediaGroup(TelegramMethod[List[Message]]):
"""A JSON-serialized array describing messages to be sent, must include 2-10 items"""
disable_notification: Optional[bool] = None
"""Sends messages `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent messages from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the messages are a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -38,6 +38,8 @@ class SendMessage(TelegramMethod[Message]):
"""Disables link previews for links in this message"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -39,6 +39,8 @@ class SendPhoto(TelegramMethod[Message]):
"""A JSON-serialized list of special entities that appear in the caption, which can be specified instead of *parse_mode*"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -55,6 +55,8 @@ class SendPoll(TelegramMethod[Message]):
"""Pass :code:`True`, if the poll needs to be immediately closed. This can be useful for poll preview."""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -18,7 +18,7 @@ if TYPE_CHECKING:
class SendSticker(TelegramMethod[Message]):
"""
Use this method to send static .WEBP or `animated <https://telegram.org/blog/animated-stickers>`_ .TGS stickers. On success, the sent :class:`aiogram.types.message.Message` is returned.
Use this method to send static .WEBP, `animated <https://telegram.org/blog/animated-stickers>`_ .TGS, or `video <https://telegram.org/blog/video-stickers-better-reactions>`_ .WEBM stickers. On success, the sent :class:`aiogram.types.message.Message` is returned.
Source: https://core.telegram.org/bots/api#sendsticker
"""
@ -31,6 +31,8 @@ class SendSticker(TelegramMethod[Message]):
"""Sticker to send. Pass a file_id as String to send a file that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get a .WEBP file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -44,6 +44,8 @@ class SendVenue(TelegramMethod[Message]):
"""Google Places type of the venue. (See `supported types <https://developers.google.com/places/web-service/supported_types>`_.)"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -49,6 +49,8 @@ class SendVideo(TelegramMethod[Message]):
"""Pass :code:`True`, if the uploaded video is suitable for streaming"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -37,6 +37,8 @@ class SendVideoNote(TelegramMethod[Message]):
"""Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side. The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail's width and height should not exceed 320. Ignored if the file is not uploaded using multipart/form-data. Thumbnails can't be reused and can be only uploaded as a new file, so you can pass 'attach://<file_attach_name>' if the thumbnail was uploaded using multipart/form-data under <file_attach_name>. :ref:`More info on Sending Files » <sending-files>`"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -41,6 +41,8 @@ class SendVoice(TelegramMethod[Message]):
"""Duration of the voice message in seconds"""
disable_notification: Optional[bool] = None
"""Sends the message `silently <https://telegram.org/blog/channels-2-0#silent-messages>`_. Users will receive a notification with no sound."""
protect_content: Optional[bool] = None
"""Protects the contents of the sent message from forwarding and saving"""
reply_to_message_id: Optional[int] = None
"""If the message is a reply, ID of the original message"""
allow_sending_without_reply: Optional[bool] = None

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class SetStickerSetThumb(TelegramMethod[bool]):
"""
Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set for animated sticker sets only. Returns :code:`True` on success.
Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set for animated sticker sets only. Video thumbnails can be set only for video sticker sets only. Returns :code:`True` on success.
Source: https://core.telegram.org/bots/api#setstickersetthumb
"""
@ -23,7 +23,7 @@ class SetStickerSetThumb(TelegramMethod[bool]):
user_id: int
"""User identifier of the sticker set owner"""
thumb: Optional[Union[InputFile, str]] = None
"""A **PNG** image with the thumbnail, must be up to 128 kilobytes in size and have width and height exactly 100px, or a **TGS** animation with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_`https://core.telegram.org/animated_stickers#technical-requirements <https://core.telegram.org/animated_stickers#technical-requirements>`_ for animated sticker technical requirements. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`. Animated sticker set thumbnail can't be uploaded via HTTP URL."""
"""A **PNG** image with the thumbnail, must be up to 128 kilobytes in size and have width and height exactly 100px, or a **TGS** animation with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_`https://core.telegram.org/stickers#animated-sticker-requirements <https://core.telegram.org/stickers#animated-sticker-requirements>`_ for animated sticker technical requirements, or a **WEBM** video with the thumbnail up to 32 kilobytes in size; see `https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_`https://core.telegram.org/stickers#video-sticker-requirements <https://core.telegram.org/stickers#video-sticker-requirements>`_ for video sticker technical requirements. Pass a *file_id* as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :ref:`More info on Sending Files » <sending-files>`. Animated sticker set thumbnails can't be uploaded via HTTP URL."""
def build_request(self, bot: Bot) -> Request:
data: Dict[str, Any] = self.dict(exclude={"thumb"})

View file

@ -18,7 +18,7 @@ class UnbanChatMember(TelegramMethod[bool]):
__returning__ = bool
chat_id: Union[int, str]
"""Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@username`)"""
"""Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`)"""
user_id: int
"""Unique identifier of the target user"""
only_if_banned: Optional[bool] = None

View file

@ -11,6 +11,6 @@ class BotCommand(MutableTelegramObject):
"""
command: str
"""Text of the command, 1-32 characters. Can contain only lowercase English letters, digits and underscores."""
"""Text of the command; 1-32 characters. Can contain only lowercase English letters, digits and underscores."""
description: str
"""Description of the command, 3-256 characters."""
"""Description of the command; 1-256 characters."""

View file

@ -1,7 +1,13 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Optional, Union
from .base import TelegramObject
if TYPE_CHECKING:
from .user import User
class ChatMember(TelegramObject):
"""
@ -16,3 +22,48 @@ class ChatMember(TelegramObject):
Source: https://core.telegram.org/bots/api#chatmember
"""
status: str
"""..."""
user: Optional[User] = None
"""*Optional*. Information about the user"""
is_anonymous: Optional[bool] = None
"""*Optional*. :code:`True`, if the user's presence in the chat is hidden"""
custom_title: Optional[str] = None
"""*Optional*. Custom title for this user"""
can_be_edited: Optional[bool] = None
"""*Optional*. :code:`True`, if the bot is allowed to edit administrator privileges of that user"""
can_manage_chat: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can access the chat event log, chat statistics, message statistics in channels, see channel members, see anonymous administrators in supergroups and ignore slow mode. Implied by any other administrator privilege"""
can_delete_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can delete messages of other users"""
can_manage_voice_chats: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can manage voice chats"""
can_restrict_members: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can restrict, ban or unban chat members"""
can_promote_members: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user)"""
can_change_info: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to change the chat title, photo and other settings"""
can_invite_users: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to invite new users to the chat"""
can_post_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can post in the channel; channels only"""
can_edit_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the administrator can edit messages of other users and can pin messages; channels only"""
can_pin_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to pin messages; groups and supergroups only"""
is_member: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is a member of the chat at the moment of the request"""
can_send_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to send text messages, contacts, locations and venues"""
can_send_media_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to send audios, documents, photos, videos, video notes and voice notes"""
can_send_polls: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to send polls"""
can_send_other_messages: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to send animations, games, stickers and use inline bots"""
can_add_web_page_previews: Optional[bool] = None
"""*Optional*. :code:`True`, if the user is allowed to add web page previews to their messages"""
until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None
"""*Optional*. Date when restrictions will be lifted for this user; unix time. If 0, then the user is restricted forever"""

View file

@ -95,7 +95,7 @@ class Message(TelegramObject):
forward_from_message_id: Optional[int] = None
"""*Optional*. For messages forwarded from channels, identifier of the original message in the channel"""
forward_signature: Optional[str] = None
"""*Optional*. For messages forwarded from channels, signature of the post author if present"""
"""*Optional*. For forwarded messages that were originally sent in channels or by an anonymous chat administrator, signature of the message sender if present"""
forward_sender_name: Optional[str] = None
"""*Optional*. Sender's name for messages forwarded from users who disallow adding a link to their account in forwarded messages"""
forward_date: Optional[int] = None

View file

@ -18,7 +18,7 @@ class MessageEntity(MutableTelegramObject):
"""
type: str
"""Type of the entity. Can be 'mention' (:code:`@username`), 'hashtag' (:code:`#hashtag`), 'cashtag' (:code:`$USD`), 'bot_command' (:code:`/start@jobs_bot`), 'url' (:code:`https://telegram.org`), 'email' (:code:`do-not-reply@telegram.org`), 'phone_number' (:code:`+1-212-555-0123`), 'bold' (**bold text**), 'italic' (*italic text*), 'underline' (underlined text), 'strikethrough' (strikethrough text), 'code' (monowidth string), 'pre' (monowidth block), 'text_link' (for clickable text URLs), 'text_mention' (for users `without usernames <https://telegram.org/blog/edit#new-mentions>`_)"""
"""Type of the entity. Currently, can be 'mention' (:code:`@username`), 'hashtag' (:code:`#hashtag`), 'cashtag' (:code:`$USD`), 'bot_command' (:code:`/start@jobs_bot`), 'url' (:code:`https://telegram.org`), 'email' (:code:`do-not-reply@telegram.org`), 'phone_number' (:code:`+1-212-555-0123`), 'bold' (**bold text**), 'italic' (*italic text*), 'underline' (underlined text), 'strikethrough' (strikethrough text), 'spoiler' (spoiler message), 'code' (monowidth string), 'pre' (monowidth block), 'text_link' (for clickable text URLs), 'text_mention' (for users `without usernames <https://telegram.org/blog/edit#new-mentions>`_)"""
offset: int
"""Offset in UTF-16 code units to the start of the entity"""
length: int

View file

@ -0,0 +1,347 @@
import asyncio
import logging
import time
from asyncio import Event, Lock
from contextlib import suppress
from types import TracebackType
from typing import Any, Awaitable, Callable, Dict, Optional, Type, Union
from aiogram import BaseMiddleware, Bot
from aiogram.dispatcher.flags.getter import get_flag
from aiogram.types import Message, TelegramObject
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL = 5.0
DEFAULT_INITIAL_SLEEP = 0.1
class ChatActionSender:
"""
This utility helps to automatically send chat action until long actions is done
to take acknowledge bot users the bot is doing something and not crashed.
Provides simply to use context manager.
Technically sender start background task with infinity loop which works
until action will be finished and sends the `chat action <https://core.telegram.org/bots/api#sendchataction>`_
every 5 seconds.
"""
def __init__(
self,
*,
chat_id: Union[str, int],
action: str = "typing",
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
bot: Optional[Bot] = None,
) -> None:
"""
:param chat_id: target chat id
:param action: chat action type
:param interval: interval between iterations
:param initial_sleep: sleep before first iteration
:param bot: instance of the bot, can be omitted from the context
"""
if bot is None:
bot = Bot.get_current(False)
self.chat_id = chat_id
self.action = action
self.interval = interval
self.initial_sleep = initial_sleep
self.bot = bot
self._lock = Lock()
self._close_event = Event()
self._closed_event = Event()
self._task: Optional[asyncio.Task[Any]] = None
@property
def running(self) -> bool:
return bool(self._task)
async def _wait(self, interval: float) -> None:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._close_event.wait(), interval)
async def _worker(self) -> None:
logger.debug(
"Started chat action %r sender in chat_id=%s via bot id=%d",
self.action,
self.chat_id,
self.bot.id,
)
try:
counter = 0
await self._wait(self.initial_sleep)
while not self._close_event.is_set():
start = time.monotonic()
logger.debug(
"Sent chat action %r to chat_id=%s via bot %d (already sent actions %d)",
self.action,
self.chat_id,
self.bot.id,
counter,
)
await self.bot.send_chat_action(chat_id=self.chat_id, action=self.action)
counter += 1
interval = self.interval - (time.monotonic() - start)
await self._wait(interval)
finally:
logger.debug(
"Finished chat action %r sender in chat_id=%s via bot id=%d",
self.action,
self.chat_id,
self.bot.id,
)
self._closed_event.set()
async def _run(self) -> None:
async with self._lock:
self._close_event.clear()
self._closed_event.clear()
if self.running:
raise RuntimeError("Already running")
self._task = asyncio.create_task(self._worker())
async def _stop(self) -> None:
async with self._lock:
if not self.running:
return
if not self._close_event.is_set():
self._close_event.set()
await self._closed_event.wait()
self._task = None
async def __aenter__(self) -> "ChatActionSender":
await self._run()
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Any:
await self._stop()
@classmethod
def typing(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `typing` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="typing",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_photo(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_photo` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_photo",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_video(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_video` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_video",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_video(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_video` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_video",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_voice(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_voice` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_voice",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_voice(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_voice` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_voice",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_document(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_document` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_document",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def choose_sticker(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `choose_sticker` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="choose_sticker",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def find_location(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `find_location` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="find_location",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def record_video_note(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `record_video_note` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="record_video_note",
interval=interval,
initial_sleep=initial_sleep,
)
@classmethod
def upload_video_note(
cls,
chat_id: Union[int, str],
bot: Optional[Bot] = None,
interval: float = DEFAULT_INTERVAL,
initial_sleep: float = DEFAULT_INITIAL_SLEEP,
) -> "ChatActionSender":
"""Create instance of the sender with `upload_video_note` action"""
return cls(
bot=bot,
chat_id=chat_id,
action="upload_video_note",
interval=interval,
initial_sleep=initial_sleep,
)
class ChatActionMiddleware(BaseMiddleware):
"""
Helps to automatically use chat action sender for all message handlers
"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
if not isinstance(event, Message):
return await handler(event, data)
bot = data["bot"]
chat_action = get_flag(data, "chat_action") or "typing"
kwargs = {}
if isinstance(chat_action, dict):
if initial_sleep := chat_action.get("initial_sleep"):
kwargs["initial_sleep"] = initial_sleep
if interval := chat_action.get("interval"):
kwargs["interval"] = interval
if action := chat_action.get("action"):
kwargs["action"] = action
elif isinstance(chat_action, bool):
kwargs["action"] = "typing"
else:
kwargs["action"] = chat_action
async with ChatActionSender(bot=bot, chat_id=event.chat.id, **kwargs):
return await handler(event, data)

View file

@ -16,7 +16,7 @@ def gettext(*args: Any, **kwargs: Any) -> str:
def lazy_gettext(*args: Any, **kwargs: Any) -> LazyProxy:
return LazyProxy(gettext, *args, **kwargs)
return LazyProxy(gettext, *args, **kwargs, enable_cache=False)
ngettext = gettext

View file

@ -118,4 +118,6 @@ class I18n(ContextInstanceMixin["I18n"]):
def lazy_gettext(
self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None
) -> LazyProxy:
return LazyProxy(self.gettext, singular=singular, plural=plural, n=n, locale=locale)
return LazyProxy(
self.gettext, singular=singular, plural=plural, n=n, locale=locale, enable_cache=False
)

View file

@ -2,10 +2,14 @@ from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast
try:
from babel import Locale
from babel import Locale, UnknownLocaleError
except ImportError: # pragma: no cover
Locale = None
class UnknownLocaleError(Exception): # type: ignore
pass
from aiogram import BaseMiddleware, Router
from aiogram.dispatcher.fsm.context import FSMContext
from aiogram.types import TelegramObject, User
@ -116,7 +120,11 @@ class SimpleI18nMiddleware(I18nMiddleware):
event_from_user: Optional[User] = data.get("event_from_user", None)
if event_from_user is None:
return self.i18n.default_locale
locale = Locale.parse(event_from_user.language_code, sep="-")
try:
locale = Locale.parse(event_from_user.language_code, sep="-")
except UnknownLocaleError:
return self.i18n.default_locale
if locale.language not in self.i18n.available_locales:
return self.i18n.default_locale
return cast(str, locale.language)

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import contextvars
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Generic, Optional, TypeVar, cast, overload
from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar, cast, overload
if TYPE_CHECKING:
from typing_extensions import Literal
@ -38,7 +38,7 @@ ContextInstance = TypeVar("ContextInstance")
class ContextInstanceMixin(Generic[ContextInstance]):
__context_instance: ClassVar[contextvars.ContextVar[ContextInstance]]
__context_instance: contextvars.ContextVar[ContextInstance]
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__()

View file

@ -0,0 +1,103 @@
=================
ChatMemberUpdated
=================
.. autoclass:: aiogram.dispatcher.filters.chat_member_updated.ChatMemberUpdatedFilter
:members:
:member-order: bysource
:undoc-members: False
You can import from :code:`aiogram.dispatcher.filters` all available
variants of `statuses`_, `status groups`_ or `transitions`_:
Statuses
========
+-------------------------+--------------------------------------+
| name | Description |
+=========================+======================================+
| :code:`CREATOR` | Chat owner |
+-------------------------+--------------------------------------+
| :code:`ADMINISTRATOR` | Chat administrator |
+-------------------------+--------------------------------------+
| :code:`MEMBER` | Member of the chat |
+-------------------------+--------------------------------------+
| :code:`RESTRICTED` | Restricted user (can be not member) |
+-------------------------+--------------------------------------+
| :code:`LEFT` | Isn't member of the chat |
+-------------------------+--------------------------------------+
| :code:`KICKED` | Kicked member by administrators |
+-------------------------+--------------------------------------+
Statuses can be extended with `is_member` flag by prefixing with
:code:`+` (for :code:`is_member == True)` or :code:`-` (for :code:`is_member == False`) symbol,
like :code:`+RESTRICTED` or :code:`-RESTRICTED`
Status groups
=============
The particular statuses can be combined via bitwise :code:`or` operator, like :code:`CREATOR | ADMINISTRATOR`
+-------------------------+-----------------------------------------------------------------------------------+
| name | Description |
+=========================+===================================================================================+
| :code:`IS_MEMBER` | Combination of :code:`(CREATOR | ADMINISTRATOR | MEMBER | +RESTRICTED)` statuses. |
+-------------------------+-----------------------------------------------------------------------------------+
| :code:`IS_ADMIN` | Combination of :code:`(CREATOR | ADMINISTRATOR)` statuses. |
+-------------------------+-----------------------------------------------------------------------------------+
| :code:`IS_NOT_MEMBER` | Combination of :code:`(LEFT | KICKED | -RESTRICTED)` statuses. |
+-------------------------+-----------------------------------------------------------------------------------+
Transitions
===========
Transitions can be defined via bitwise shift operators :code:`>>` and :code:`<<`.
Old chat member status should be defined in the left side for :code:`>>` operator (right side for :code:`<<`)
and new status should be specified on the right side for :code:`>>` operator (left side for :code:`<<`)
The direction of transition can be changed via bitwise inversion operator: :code:`~JOIN_TRANSITION`
will produce swap of old and new statuses.
+-----------------------------+-----------------------------------------------------------------------+
| name | Description |
+=============================+=======================================================================+
| :code:`JOIN_TRANSITION` | Means status changed from :code:`IS_NOT_MEMBER` to :code:`IS_MEMBER` |
| | (:code:`IS_NOT_MEMBER >> IS_MEMBER`) |
+-----------------------------+-----------------------------------------------------------------------+
| :code:`LEAVE_TRANSITION` | Means status changed from :code:`IS_MEMBER` to :code:`IS_NOT_MEMBER` |
| | (:code:`~JOIN_TRANSITION`) |
+-----------------------------+-----------------------------------------------------------------------+
| :code:`PROMOTED_TRANSITION` | Means status changed from |
| | :code:`(MEMBER | RESTRICTED | LEFT | KICKED) >> ADMINISTRATOR` |
| | (:code:`(MEMBER | RESTRICTED | LEFT | KICKED) >> ADMINISTRATOR`) |
+-----------------------------+-----------------------------------------------------------------------+
.. note::
Note that if you define the status unions (via :code:`|`) you will need to add brackets for the statement
before use shift operator in due to operator priorities.
Usage
=====
Handle user leave or join events
.. code-block:: python
from aiogram.dispatcher.filters import IS_MEMBER, IS_NOT_MEMBER
@router.chat_member(chat_member_updated=IS_MEMBER >> IS_NOT_MEMBER)
async def on_user_leave(event: ChatMemberUpdated): ...
@router.chat_member(chat_member_updated=IS_NOT_MEMBER >> IS_MEMBER)
async def on_user_join(event: ChatMemberUpdated): ...
Or construct your own terms via using pre-defined set of statuses and transitions.
Allowed handlers
================
Allowed update types for this filter:
- `my_chat_member`
- `chat_member`

View file

@ -18,6 +18,7 @@ Here is list of builtin filters:
command
content_types
text
chat_member_updated
exception
magic_filters
magic_data

89
docs/dispatcher/flags.rst Normal file
View file

@ -0,0 +1,89 @@
=====
Flags
=====
Flags is a markers for handlers that can be used in `middlewares <#use-in-middlewares>`_
or special `utilities <#use-in-utilities>`_ to make classification of the handlers.
Flags can be added to the handler via `decorators <#via-decorators>`_,
`handlers registration <#via-handler-registration-method>`_ or
`filters <via-filters>`_.
Via decorators
==============
For example mark handler with `chat_action` flag
.. code-block:: python
from aiogram import flags
@flags.chat_action
async def my_handler(message: Message)
Or just for rate-limit or something else
.. code-block:: python
from aiogram import flags
@flags.rate_limit(rate=2, key="something")
async def my_handler(message: Message)
Via handler registration method
===============================
.. code-block:: python
@router.message(..., flags={'chat_action': 'typing', 'rate_limit': {'rate': 5}})
Via filters
===========
.. code-block:: python
class Command(BaseFilter):
...
def update_handler_flags(self, flags: Dict[str, Any]) -> None:
commands = flags.setdefault("commands", [])
commands.append(self)
Use in middlewares
==================
.. automodule:: aiogram.dispatcher.flags.getter
:members:
Example in middlewares
----------------------
.. code-block:: python
async def my_middleware(handler, event, data):
typing = get_flag(data, "typing") # Check that handler marked with `typing` flag
if not typing:
return await handler(event, data)
async with ChatActionSender.typing(chat_id=event.chat.id):
return await handler(event, data)
Use in utilities
================
For example you can collect all registered commands with handler description and then it can be used for generating commands help
.. code-block:: python
def collect_commands(router: Router) -> Generator[Tuple[Command, str], None, None]:
for handler in router.message.handlers:
if "commands" not in handler.flags: # ignore all handler without commands
continue
# the Command filter adds the flag with list of commands attached to the handler
for command in handler.flags["commands"]:
yield command, handler.callback.__doc__ or ""
# Recursively extract commands from nested routers
for sub_router in router.sub_routers:
yield from collect_commands(sub_router)

View file

@ -24,3 +24,4 @@ Dispatcher is subclass of router and should be always is root router.
filters/index
middlewares
finite_state_machine/index
flags

View file

@ -0,0 +1,56 @@
==================
Chat action sender
==================
Sender
======
.. autoclass:: aiogram.utils.chat_action.ChatActionSender
:members: __init__,running,typing,upload_photo,record_video,upload_video,record_voice,upload_voice,upload_document,choose_sticker,find_location,record_video_note,upload_video_note
Usage
-----
.. code-block:: python
async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
# Do something...
# Perform some long calculations
await message.answer(result)
Middleware
==========
.. autoclass:: aiogram.utils.chat_action.ChatActionMiddleware
Usage
-----
Before usa should be registered for the `message` event
.. code-block:: python
<router or dispatcher>.message.middleware(ChatActionMiddleware())
After this action all handlers which works longer than `initial_sleep` will produce the '`typing`' chat action.
Also sender can be customized via flags feature for particular handler.
Change only action type:
.. code-block:: python
@router.message(...)
@flags.chat_action("sticker")
async def my_handler(message: Message): ...
Change sender configuration:
.. code-block:: python
@router.message(...)
@flags.chat_action(initial_sleep=2, action="upload_document", interval=3)
async def my_handler(message: Message): ...

View file

@ -6,3 +6,4 @@ Utils
i18n
keyboard
chat_action

793
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "aiogram"
version = "3.0.0-beta.1"
version = "3.0.0-beta.2"
description = "Modern and fully asynchronous framework for Telegram Bot API"
authors = [
"Alex Root Junior <jroot.junior@gmail.com>",
@ -37,53 +37,55 @@ classifiers = [
[tool.poetry.dependencies]
python = "^3.8"
magic-filter = "^1.0.4"
aiohttp = "^3.8.0"
pydantic = "^1.8.2"
aiofiles = "^0.7.0"
magic-filter = "^1.0.5"
aiohttp = "^3.8.1"
pydantic = "^1.9.0"
aiofiles = "^0.8.0"
# Fast
uvloop = { version = "^0.16.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'", optional = true }
# i18n
Babel = { version = "^2.9.1", optional = true }
# Proxy
aiohttp-socks = { version = "^0.5.5", optional = true }
aiohttp-socks = {version = "^0.7.1", optional = true}
# Redis
aioredis = { version = "^2.0.0", optional = true }
aioredis = {version = "^2.0.1", optional = true}
# Docs
Sphinx = { version = "^4.2.0", optional = true }
sphinx-intl = { version = "^2.0.1", optional = true }
sphinx-autobuild = { version = "^2021.3.14", optional = true }
sphinx-copybutton = { version = "^0.4.0", optional = true }
furo = { version = "^2021.9.8", optional = true }
sphinx-copybutton = {version = "^0.5.0", optional = true}
furo = {version = "^2022.2.14", optional = true}
sphinx-prompt = { version = "^1.5.0", optional = true }
Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true }
towncrier = { version = "^21.3.0", optional = true }
towncrier = {version = "^21.9.0", optional = true}
pygments = { version = "^2.4", optional = true }
pymdown-extensions = { version = "^8.0", optional = true }
pymdown-extensions = {version = "^9.2", optional = true}
markdown-include = { version = "^0.6", optional = true }
Pygments = {version = "^2.11.2", optional = true}
[tool.poetry.dev-dependencies]
ipython = "^7.22.0"
black = "^21.4b2"
isort = "^5.8.0"
flake8 = "^3.9.1"
ipython = "^8.0.1"
black = "^22.1.0"
isort = "^5.10.1"
flake8 = "^4.0.1"
flake8-html = "^0.4.1"
mypy = "^0.910"
pytest = "^6.2.3"
mypy = "^0.931"
pytest = "^7.0.1"
pytest-html = "^3.1.1"
pytest-asyncio = "^0.15.1"
pytest-asyncio = "^0.18.1"
pytest-lazy-fixture = "^0.6.3"
pytest-mock = "^3.6.0"
pytest-mypy = "^0.8.1"
pytest-cov = "^2.11.1"
pytest-aiohttp = "^0.3.0"
aresponses = "^2.1.4"
pytest-mock = "^3.7.0"
pytest-mypy = "^0.9.1"
pytest-cov = "^3.0.0"
pytest-aiohttp = "^1.0.4"
aresponses = "^2.1.5"
asynctest = "^0.13.0"
toml = "^0.10.2"
pre-commit = "^2.15.0"
packaging = "^20.3"
typing-extensions = "^3.7.4"
pre-commit = "^2.17.0"
packaging = "^21.3"
typing-extensions = "^4.1.1"
sentry-sdk = "^1.5.5"
[tool.poetry.extras]

View file

@ -4,9 +4,13 @@ import pytest
from _pytest.config import UsageError
from aioredis.connection import parse_url as parse_redis_url
from aiogram import Bot
from aiogram.dispatcher.fsm.storage.memory import MemoryStorage
from aiogram.dispatcher.fsm.storage.redis import RedisStorage
from aiogram import Bot, Dispatcher
from aiogram.dispatcher.fsm.storage.memory import (
DisabledEventIsolation,
MemoryStorage,
SimpleEventIsolation,
)
from aiogram.dispatcher.fsm.storage.redis import RedisEventIsolation, RedisStorage
from tests.mocked_bot import MockedBot
DATA_DIR = Path(__file__).parent / "data"
@ -67,6 +71,42 @@ async def memory_storage():
await storage.close()
@pytest.fixture()
@pytest.mark.redis
async def redis_isolation(redis_server):
if not redis_server:
pytest.skip("Redis is not available here")
isolation = RedisEventIsolation.from_url(redis_server)
try:
await isolation.redis.info()
except ConnectionError as e:
pytest.skip(str(e))
try:
yield isolation
finally:
conn = await isolation.redis
await conn.flushdb()
await isolation.close()
@pytest.fixture()
async def lock_isolation():
isolation = SimpleEventIsolation()
try:
yield isolation
finally:
await isolation.close()
@pytest.fixture()
async def disabled_isolation():
isolation = DisabledEventIsolation()
try:
yield isolation
finally:
await isolation.close()
@pytest.fixture()
def bot():
bot = MockedBot()
@ -75,3 +115,13 @@ def bot():
yield bot
finally:
Bot.reset_current(token)
@pytest.fixture()
async def dispatcher():
dp = Dispatcher()
await dp.emit_startup()
try:
yield dp
finally:
await dp.emit_shutdown()

View file

@ -76,20 +76,15 @@ class TestDispatcher:
assert dp.update.handlers[0].callback == dp._listen_update
assert dp.update.outer_middlewares
def test_parent_router(self):
dp = Dispatcher()
def test_parent_router(self, dispatcher: Dispatcher):
with pytest.raises(RuntimeError):
dp.parent_router = Router()
assert dp.parent_router is None
dp._parent_router = Router()
assert dp.parent_router is None
dispatcher.parent_router = Router()
assert dispatcher.parent_router is None
dispatcher._parent_router = Router()
assert dispatcher.parent_router is None
@pytest.mark.parametrize("isolate_events", (True, False))
async def test_feed_update(self, isolate_events):
dp = Dispatcher(isolate_events=isolate_events)
bot = Bot("42:TEST")
@dp.message()
async def test_feed_update(self, dispatcher: Dispatcher, bot: MockedBot):
@dispatcher.message()
async def my_handler(message: Message, **kwargs):
assert "bot" in kwargs
assert isinstance(kwargs["bot"], Bot)
@ -97,7 +92,7 @@ class TestDispatcher:
return message.text
results_count = 0
result = await dp.feed_update(
result = await dispatcher.feed_update(
bot=bot,
update=Update(
update_id=42,

View file

@ -0,0 +1,345 @@
from datetime import datetime
import pytest
from aiogram.dispatcher.filters.chat_member_updated import (
ADMINISTRATOR,
IS_MEMBER,
JOIN_TRANSITION,
LEAVE_TRANSITION,
ChatMemberUpdatedFilter,
_MemberStatusGroupMarker,
_MemberStatusMarker,
_MemberStatusTransition,
)
from aiogram.types import Chat, ChatMember, ChatMemberUpdated, User
class TestMemberStatusMarker:
def test_str(self):
marker = _MemberStatusMarker("test")
assert str(marker) == "TEST"
assert str(+marker) == "+TEST"
assert str(-marker) == "-TEST"
def test_pos(self):
marker = _MemberStatusMarker("test")
assert marker.is_member is None
positive_marker = +marker
assert positive_marker is not marker
assert marker.is_member is None
assert positive_marker.is_member is True
def test_neg(self):
marker = _MemberStatusMarker("test")
assert marker.is_member is None
negative_marker = -marker
assert negative_marker is not marker
assert marker.is_member is None
assert negative_marker.is_member is False
def test_or(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
combination = marker1 | marker2
assert isinstance(combination, _MemberStatusGroupMarker)
assert marker1 in combination.statuses
assert marker2 in combination.statuses
combination2 = marker1 | marker1
assert isinstance(combination2, _MemberStatusGroupMarker)
assert len(combination2.statuses) == 1
marker3 = _MemberStatusMarker("test3")
combination3 = marker3 | combination
assert isinstance(combination3, _MemberStatusGroupMarker)
assert marker3 in combination3.statuses
assert len(combination3.statuses) == 3
assert combination3 is not combination
with pytest.raises(TypeError):
marker1 | 42
def test_rshift(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
transition = marker1 >> marker2
assert isinstance(transition, _MemberStatusTransition)
assert marker1 in transition.old.statuses
assert marker2 in transition.new.statuses
transition2 = marker1 >> (marker2 | marker3)
assert isinstance(transition2, _MemberStatusTransition)
with pytest.raises(TypeError):
marker1 >> 42
def test_lshift(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
transition = marker1 << marker2
assert isinstance(transition, _MemberStatusTransition)
assert marker2 in transition.old.statuses
assert marker1 in transition.new.statuses
transition2 = marker1 << (marker2 | marker3)
assert isinstance(transition2, _MemberStatusTransition)
with pytest.raises(TypeError):
marker1 << 42
def test_hash(self):
marker1 = _MemberStatusMarker("test1")
marker1_1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
assert hash(marker1) != hash(marker2)
assert hash(marker1) == hash(marker1_1)
assert hash(marker1) != hash(-marker1)
@pytest.mark.parametrize(
"name,is_member,member,result",
[
["test", None, ChatMember(status="member"), False],
["test", None, ChatMember(status="test"), True],
["test", True, ChatMember(status="test"), False],
["test", True, ChatMember(status="test", is_member=True), True],
["test", True, ChatMember(status="test", is_member=False), False],
],
)
def test_check(self, name, is_member, member, result):
marker = _MemberStatusMarker(name, is_member=is_member)
assert marker.check(member=member) == result
class TestMemberStatusGroupMarker:
def test_init_unique(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
group = _MemberStatusGroupMarker(marker1, marker1, marker2, marker3)
assert len(group.statuses) == 3
def test_init_empty(self):
with pytest.raises(ValueError):
_MemberStatusGroupMarker()
def test_or(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
marker4 = _MemberStatusMarker("test4")
group1 = _MemberStatusGroupMarker(marker1, marker2)
group2 = _MemberStatusGroupMarker(marker3, marker4)
group3 = group1 | marker3
assert isinstance(group3, _MemberStatusGroupMarker)
assert len(group3.statuses) == 3
group4 = group1 | group2
assert isinstance(group4, _MemberStatusGroupMarker)
assert len(group4.statuses) == 4
with pytest.raises(TypeError):
group4 | 42
def test_rshift(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
group1 = _MemberStatusGroupMarker(marker1, marker2)
group2 = _MemberStatusGroupMarker(marker1, marker3)
transition1 = group1 >> marker1
assert isinstance(transition1, _MemberStatusTransition)
assert transition1.old is group1
assert marker1 in transition1.new.statuses
transition2 = group1 >> group2
assert isinstance(transition2, _MemberStatusTransition)
with pytest.raises(TypeError):
group1 >> 42
def test_lshift(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
marker3 = _MemberStatusMarker("test3")
group1 = _MemberStatusGroupMarker(marker1, marker2)
group2 = _MemberStatusGroupMarker(marker1, marker3)
transition1 = group1 << marker1
assert isinstance(transition1, _MemberStatusTransition)
assert transition1.new is group1
assert marker1 in transition1.old.statuses
transition2 = group1 << group2
assert isinstance(transition2, _MemberStatusTransition)
with pytest.raises(TypeError):
group1 << 42
def test_str(self):
marker1 = _MemberStatusMarker("test1")
marker1_1 = +marker1
marker2 = _MemberStatusMarker("test2")
group1 = marker1 | marker1
assert str(group1) == "TEST1"
group2 = marker1 | marker2
assert str(group2) == "(TEST1 | TEST2)"
group3 = marker1 | marker1_1
assert str(group3) == "(+TEST1 | TEST1)"
@pytest.mark.parametrize(
"status,result",
[
["test", False],
["test1", True],
["test2", True],
],
)
def test_check(self, status, result):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
group = marker1 | marker2
assert group.check(member=ChatMember(status=status)) is result
class TestMemberStatusTransition:
def test_invert(self):
marker1 = _MemberStatusMarker("test1")
marker2 = _MemberStatusMarker("test2")
transition1 = marker1 >> marker2
transition2 = ~transition1
assert transition1 is not transition2
assert transition1.old == transition2.new
assert transition1.new == transition2.old
assert str(transition1) == "TEST1 >> TEST2"
assert str(transition2) == "TEST2 >> TEST1"
@pytest.mark.parametrize(
"transition,old,new,result",
[
[JOIN_TRANSITION, ChatMember(status="left"), ChatMember(status="member"), True],
[
JOIN_TRANSITION,
ChatMember(status="restricted", is_member=True),
ChatMember(status="member"),
False,
],
[
JOIN_TRANSITION,
ChatMember(status="restricted", is_member=False),
ChatMember(status="member"),
True,
],
[
JOIN_TRANSITION,
ChatMember(status="member"),
ChatMember(status="restricted", is_member=False),
False,
],
[
LEAVE_TRANSITION,
ChatMember(status="member"),
ChatMember(status="restricted", is_member=False),
True,
],
],
)
def test_check(self, transition, old, new, result):
assert transition.check(old=old, new=new) == result
class TestChatMemberUpdatedStatusFilter:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"transition,old,new,result",
[
[JOIN_TRANSITION, ChatMember(status="left"), ChatMember(status="member"), True],
[
JOIN_TRANSITION,
ChatMember(status="restricted", is_member=True),
ChatMember(status="member"),
False,
],
[
JOIN_TRANSITION,
ChatMember(status="restricted", is_member=False),
ChatMember(status="member"),
True,
],
[
JOIN_TRANSITION,
ChatMember(status="member"),
ChatMember(status="restricted", is_member=False),
False,
],
[
LEAVE_TRANSITION,
ChatMember(status="member"),
ChatMember(status="restricted", is_member=False),
True,
],
[
ADMINISTRATOR,
ChatMember(status="member"),
ChatMember(status="administrator"),
True,
],
[
IS_MEMBER,
ChatMember(status="restricted", is_member=False),
ChatMember(status="member"),
True,
],
],
)
async def test_call(self, transition, old, new, result):
updated_filter = ChatMemberUpdatedFilter(member_status_changed=transition)
user = User(id=42, first_name="Test", is_bot=False)
update = {
"user": user,
"until_date": datetime.now(),
"is_anonymous": False,
"can_be_edited": True,
"can_manage_chat": True,
"can_delete_messages": True,
"can_manage_voice_chats": True,
"can_restrict_members": True,
"can_promote_members": True,
"can_change_info": True,
"can_invite_users": True,
"can_post_messages": True,
"can_edit_messages": True,
"can_pin_messages": True,
"can_send_messages": True,
"can_send_media_messages": True,
"can_send_polls": True,
"can_send_other_messages": True,
"can_add_web_page_previews": True,
}
event = ChatMemberUpdated(
chat=Chat(id=42, type="test"),
from_user=user,
old_chat_member=old.copy(update=update),
new_chat_member=new.copy(update=update),
date=datetime.now(),
)
assert await updated_filter(event) is result

View file

@ -0,0 +1,66 @@
import pytest
from aiogram.dispatcher.flags.flag import Flag, FlagDecorator, FlagGenerator
@pytest.fixture(name="flag")
def flag_fixture() -> Flag:
return Flag("test", True)
@pytest.fixture(name="flag_decorator")
def flag_decorator_fixture(flag: Flag) -> FlagDecorator:
return FlagDecorator(flag)
@pytest.fixture(name="flag_generator")
def flag_flag_generator() -> FlagGenerator:
return FlagGenerator()
class TestFlagDecorator:
def test_with_value(self, flag_decorator: FlagDecorator):
new_decorator = flag_decorator._with_value(True)
assert new_decorator is not flag_decorator
assert new_decorator.flag is not flag_decorator.flag
assert new_decorator.flag
def test_call_invalid(self, flag_decorator: FlagDecorator):
with pytest.raises(ValueError):
flag_decorator(True, test=True)
def test_call_with_function(self, flag_decorator: FlagDecorator):
def func():
pass
decorated = flag_decorator(func)
assert decorated is func
assert hasattr(decorated, "aiogram_flag")
def test_call_with_arg(self, flag_decorator: FlagDecorator):
new_decorator = flag_decorator("hello")
assert new_decorator is not flag_decorator
assert new_decorator.flag.value == "hello"
def test_call_with_kwargs(self, flag_decorator: FlagDecorator):
new_decorator = flag_decorator(test=True)
assert new_decorator is not flag_decorator
assert isinstance(new_decorator.flag.value, dict)
assert "test" in new_decorator.flag.value
class TestFlagGenerator:
def test_getattr(self):
generator = FlagGenerator()
assert isinstance(generator.foo, FlagDecorator)
assert isinstance(generator.bar, FlagDecorator)
assert generator.foo is not generator.foo
assert generator.foo is not generator.bar
def test_failed_getattr(self):
generator = FlagGenerator()
with pytest.raises(AttributeError):
generator._something

View file

@ -0,0 +1,64 @@
from unittest.mock import patch
import pytest
from aiogram import F
from aiogram.dispatcher.event.handler import HandlerObject
from aiogram.dispatcher.flags.getter import (
check_flags,
extract_flags,
extract_flags_from_object,
get_flag,
)
class TestGetters:
def test_extract_flags_from_object(self):
def func():
pass
assert extract_flags_from_object(func) == {}
func.aiogram_flag = {"test": True}
assert extract_flags_from_object(func) == func.aiogram_flag
@pytest.mark.parametrize(
"obj,result",
[
[None, {}],
[{}, {}],
[{"handler": None}, {}],
[{"handler": HandlerObject(lambda: True, flags={"test": True})}, {"test": True}],
],
)
def test_extract_flags(self, obj, result):
assert extract_flags(obj) == result
@pytest.mark.parametrize(
"obj,name,default,result",
[
[None, "test", None, None],
[None, "test", 42, 42],
[{}, "test", None, None],
[{}, "test", 42, 42],
[{"handler": None}, "test", None, None],
[{"handler": None}, "test", 42, 42],
[{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test", None, True],
[{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test2", None, None],
[{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test2", 42, 42],
],
)
def test_get_flag(self, obj, name, default, result):
assert get_flag(obj, name, default=default) == result
@pytest.mark.parametrize(
"flags,magic,result",
[
[{}, F.test, None],
[{"test": True}, F.test, True],
[{"test": True}, F.spam, None],
],
)
def test_check_flag(self, flags, magic, result):
with patch("aiogram.dispatcher.flags.getter.extract_flags", return_value=flags):
assert check_flags(object(), magic) == result

View file

@ -0,0 +1,30 @@
import pytest
from aiogram.dispatcher.fsm.storage.base import BaseEventIsolation, StorageKey
from tests.mocked_bot import MockedBot
pytestmark = pytest.mark.asyncio
@pytest.fixture(name="storage_key")
def create_storate_key(bot: MockedBot):
return StorageKey(chat_id=-42, user_id=42, bot_id=bot.id)
@pytest.mark.parametrize(
"isolation",
[
pytest.lazy_fixture("redis_isolation"),
pytest.lazy_fixture("lock_isolation"),
pytest.lazy_fixture("disabled_isolation"),
],
)
class TestIsolations:
async def test_lock(
self,
bot: MockedBot,
isolation: BaseEventIsolation,
storage_key: StorageKey,
):
async with isolation.lock(bot=bot, key=storage_key):
assert True, "You are kidding me?"

View file

@ -1,9 +1,11 @@
from typing import Literal
import pytest
from aiogram.dispatcher.fsm.storage.base import DEFAULT_DESTINY, StorageKey
from aiogram.dispatcher.fsm.storage.redis import DefaultKeyBuilder
from aiogram.dispatcher.fsm.storage.redis import (
DefaultKeyBuilder,
RedisEventIsolation,
RedisStorage,
)
pytestmark = pytest.mark.asyncio
@ -45,3 +47,11 @@ class TestRedisDefaultKeyBuilder:
)
with pytest.raises(ValueError):
key_builder.build(key, FIELD)
def test_create_isolation(self):
fake_redis = object()
storage = RedisStorage(redis=fake_redis)
isolation = storage.create_isolation()
assert isinstance(isolation, RedisEventIsolation)
assert isolation.redis is fake_redis
assert isolation.key_builder is storage.key_builder

View file

@ -16,11 +16,6 @@ def create_storate_key(bot: MockedBot):
[pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("memory_storage")],
)
class TestStorages:
async def test_lock(self, bot: MockedBot, storage: BaseStorage, storage_key: StorageKey):
# TODO: ?!?
async with storage.lock(bot=bot, key=storage_key):
assert True, "You are kidding me?"
async def test_set_state(self, bot: MockedBot, storage: BaseStorage, storage_key: StorageKey):
assert await storage.get_state(bot=bot, key=storage_key) is None

View file

@ -1,4 +1,6 @@
import asyncio
import time
from asyncio import Event
from dataclasses import dataclass
from typing import Any, Dict
@ -19,6 +21,12 @@ from aiogram.methods import GetMe, Request
from aiogram.types import Message, User
from tests.mocked_bot import MockedBot
try:
from asynctest import CoroutineMock, patch
except ImportError:
from unittest.mock import AsyncMock as CoroutineMock # type: ignore
from unittest.mock import patch
class TestAiohttpServer:
def test_setup_application(self):
@ -74,8 +82,11 @@ class TestSimpleRequestHandler:
app = Application()
dp = Dispatcher()
handler_event = Event()
@dp.message(F.text == "test")
def handle_message(msg: Message):
handler_event.set()
return msg.answer("PASS")
handler = SimpleRequestHandler(
@ -97,8 +108,15 @@ class TestSimpleRequestHandler:
assert not result
handler.handle_in_background = True
resp = await self.make_reqest(client=client)
assert resp.status == 200
with patch(
"aiogram.dispatcher.dispatcher.Dispatcher.silent_call_request",
new_callable=CoroutineMock,
) as mocked_silent_call_request:
handler_event.clear()
resp = await self.make_reqest(client=client)
assert resp.status == 200
await asyncio.wait_for(handler_event.wait(), timeout=1)
mocked_silent_call_request.assert_awaited()
result = await resp.json()
assert not result

View file

@ -0,0 +1,129 @@
import asyncio
import time
from datetime import datetime
import pytest
from aiogram import Bot, flags
from aiogram.dispatcher.event.handler import HandlerObject
from aiogram.types import Chat, Message, User
from aiogram.utils.chat_action import ChatActionMiddleware, ChatActionSender
from tests.mocked_bot import MockedBot
try:
from asynctest import CoroutineMock, patch
except ImportError:
from unittest.mock import AsyncMock as CoroutineMock # type: ignore
from unittest.mock import patch
pytestmarm = pytest.mark.asyncio
class TestChatActionSender:
async def test_wait(self, bot: Bot, loop: asyncio.BaseEventLoop):
sender = ChatActionSender.typing(bot=bot, chat_id=42)
loop.call_soon(sender._close_event.set)
start = time.monotonic()
await sender._wait(1)
assert time.monotonic() - start < 1
@pytest.mark.parametrize(
"action",
[
"typing",
"upload_photo",
"record_video",
"upload_video",
"record_voice",
"upload_voice",
"upload_document",
"choose_sticker",
"find_location",
"record_video_note",
"upload_video_note",
],
)
@pytest.mark.parametrize("pass_bot", [True, False])
async def test_factory(self, action: str, bot: MockedBot, pass_bot: bool):
sender_factory = getattr(ChatActionSender, action)
sender = sender_factory(chat_id=42, bot=bot if pass_bot else None)
assert isinstance(sender, ChatActionSender)
assert sender.action == action
assert sender.chat_id == 42
assert sender.bot is bot
async def test_worker(self, bot: Bot):
with patch(
"aiogram.client.bot.Bot.send_chat_action",
new_callable=CoroutineMock,
) as mocked_send_chat_action:
async with ChatActionSender.typing(
bot=bot, chat_id=42, interval=0.01, initial_sleep=0
):
await asyncio.sleep(0.1)
assert mocked_send_chat_action.await_count > 1
mocked_send_chat_action.assert_awaited_with(action="typing", chat_id=42)
async def test_contextmanager(self, bot: MockedBot):
sender: ChatActionSender = ChatActionSender.typing(bot=bot, chat_id=42)
assert not sender.running
await sender._stop() # nothing
async with sender:
assert sender.running
assert not sender._close_event.is_set()
with pytest.raises(RuntimeError):
await sender._run()
assert not sender.running
class TestChatActionMiddleware:
@pytest.mark.parametrize(
"value",
[
None,
"sticker",
{"action": "upload_photo"},
{"interval": 1, "initial_sleep": 0.5},
],
)
async def test_call_default(self, value, bot: Bot):
async def handler(event, data):
return "OK"
if value is None:
handler1 = flags.chat_action(handler)
else:
handler1 = flags.chat_action(value)(handler)
middleware = ChatActionMiddleware()
with patch(
"aiogram.utils.chat_action.ChatActionSender._run",
new_callable=CoroutineMock,
) as mocked_run, patch(
"aiogram.utils.chat_action.ChatActionSender._stop",
new_callable=CoroutineMock,
) as mocked_stop:
data = {"handler": HandlerObject(callback=handler1), "bot": bot}
message = Message(
chat=Chat(id=42, type="private", title="Test"),
from_user=User(id=42, is_bot=False, first_name="Test"),
date=datetime.now(),
message_id=42,
)
result = await middleware(handler=handler1, event=None, data=data)
assert result == "OK"
mocked_run.assert_not_awaited()
mocked_stop.assert_not_awaited()
result = await middleware(
handler=handler1,
event=message,
data=data,
)
assert result == "OK"
mocked_run.assert_awaited()
mocked_stop.assert_awaited()

View file

@ -114,6 +114,24 @@ class TestSimpleI18nMiddleware:
assert middleware not in dp.update.outer_middlewares
assert middleware in dp.message.outer_middlewares
async def test_get_unknown_locale(self, i18n: I18n):
dp = Dispatcher()
middleware = SimpleI18nMiddleware(i18n=i18n)
middleware.setup(router=dp)
locale = await middleware.get_locale(
None,
{
"event_from_user": User(
id=42,
is_bot=False,
first_name="Test",
language_code="unknown",
)
},
)
assert locale == i18n.default_locale
@pytest.mark.asyncio
class TestConstI18nMiddleware: