mirror of
https://github.com/aiogram/aiogram.git
synced 2026-04-08 16:37:47 +00:00
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:
parent
ac7f2dc408
commit
7776cf9cf6
77 changed files with 2485 additions and 502 deletions
|
|
@ -1 +1 @@
|
|||
5.5
|
||||
5.7
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ exclude_lines =
|
|||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
@abstractmethod
|
||||
@overload
|
||||
|
|
|
|||
1
CHANGES/830.misc
Normal file
1
CHANGES/830.misc
Normal file
|
|
@ -0,0 +1 @@
|
|||
Logger name for processing events is changed to :code:`aiogram.events`.
|
||||
1
CHANGES/835.misc
Normal file
1
CHANGES/835.misc
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added full support of Telegram Bot API 5.6 and 5.7
|
||||
1
CHANGES/836.feature
Normal file
1
CHANGES/836.feature
Normal 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
3
CHANGES/837.feature
Normal 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
2
CHANGES/838.misc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
**BREAKING**
|
||||
Events isolation mechanism is moved from FSM storages to standalone managers
|
||||
1
CHANGES/839.bugix
Normal file
1
CHANGES/839.bugix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Fixed I18n lazy-proxy. Disabled caching.
|
||||
4
Makefile
4
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
179
aiogram/dispatcher/filters/chat_member_updated.py
Normal file
179
aiogram/dispatcher/filters/chat_member_updated.py
Normal 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
|
||||
0
aiogram/dispatcher/flags/__init__.py
Normal file
0
aiogram/dispatcher/flags/__init__.py
Normal file
60
aiogram/dispatcher/flags/flag.py
Normal file
60
aiogram/dispatcher/flags/flag.py
Normal 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))
|
||||
56
aiogram/dispatcher/flags/getter.py
Normal file
56
aiogram/dispatcher/flags/getter.py
Normal 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))
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
347
aiogram/utils/chat_action.py
Normal file
347
aiogram/utils/chat_action.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__()
|
||||
|
|
|
|||
103
docs/dispatcher/filters/chat_member_updated.rst
Normal file
103
docs/dispatcher/filters/chat_member_updated.rst
Normal 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`
|
||||
|
|
@ -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
89
docs/dispatcher/flags.rst
Normal 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)
|
||||
|
|
@ -24,3 +24,4 @@ Dispatcher is subclass of router and should be always is root router.
|
|||
filters/index
|
||||
middlewares
|
||||
finite_state_machine/index
|
||||
flags
|
||||
|
|
|
|||
56
docs/utils/chat_action.rst
Normal file
56
docs/utils/chat_action.rst
Normal 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): ...
|
||||
|
|
@ -6,3 +6,4 @@ Utils
|
|||
|
||||
i18n
|
||||
keyboard
|
||||
chat_action
|
||||
|
|
|
|||
793
poetry.lock
generated
793
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
345
tests/test_dispatcher/test_filters/test_chat_member_updated.py
Normal file
345
tests/test_dispatcher/test_filters/test_chat_member_updated.py
Normal 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
|
||||
0
tests/test_dispatcher/test_flags/__init__.py
Normal file
0
tests/test_dispatcher/test_flags/__init__.py
Normal file
66
tests/test_dispatcher/test_flags/test_decorator.py
Normal file
66
tests/test_dispatcher/test_flags/test_decorator.py
Normal 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
|
||||
64
tests/test_dispatcher/test_flags/test_getter.py
Normal file
64
tests/test_dispatcher/test_flags/test_getter.py
Normal 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
|
||||
30
tests/test_dispatcher/test_fsm/storage/test_isolation.py
Normal file
30
tests/test_dispatcher/test_fsm/storage/test_isolation.py
Normal 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?"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
129
tests/test_utils/test_chat_action.py
Normal file
129
tests/test_utils/test_chat_action.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue