diff --git a/Makefile b/Makefile index da6493d9..662ad49f 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ upload: release: make clean - make test + #make test make build make tag @echo "Released aiogram $(AIOGRAM_VERSION)" diff --git a/README.md b/README.md index fca118a0..811ad4c9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.6-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) diff --git a/README.rst b/README.rst index 6df651a2..0ec4b454 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ AIOGramBot :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.6-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/aiogram/__init__.py b/aiogram/__init__.py index ceca7f58..3c8f6014 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.14.3' -__api_version__ = '5.3' +__version__ = '2.18' +__api_version__ = '5.6' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 1bf00d47..f95e35b1 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -189,7 +189,7 @@ class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api - List is updated to Bot API 5.3 + List is updated to Bot API 5.6 """ mode = HelperMode.lowerCamelCase @@ -230,11 +230,15 @@ class Methods(Helper): RESTRICT_CHAT_MEMBER = Item() # restrictChatMember PROMOTE_CHAT_MEMBER = Item() # promoteChatMember SET_CHAT_ADMINISTRATOR_CUSTOM_TITLE = Item() # setChatAdministratorCustomTitle + BAN_CHAT_SENDER_CHAT = Item() # banChatSenderChat + UNBAN_CHAT_SENDER_CHAT = Item() # unbanChatSenderChat SET_CHAT_PERMISSIONS = Item() # setChatPermissions EXPORT_CHAT_INVITE_LINK = Item() # exportChatInviteLink CREATE_CHAT_INVITE_LINK = Item() # createChatInviteLink EDIT_CHAT_INVITE_LINK = Item() # editChatInviteLink REVOKE_CHAT_INVITE_LINK = Item() # revokeChatInviteLink + APPROVE_CHAT_JOIN_REQUEST = Item() # approveChatJoinRequest + DECLINE_CHAT_JOIN_REQUEST = Item() # declineChatJoinRequest SET_CHAT_PHOTO = Item() # setChatPhoto DELETE_CHAT_PHOTO = Item() # deleteChatPhoto SET_CHAT_TITLE = Item() # setChatTitle diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 07e44c1c..f885e6dc 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -1,6 +1,8 @@ import asyncio import contextlib import io +import os +import pathlib import ssl import typing import warnings @@ -35,6 +37,7 @@ class BaseBot: proxy_auth: Optional[aiohttp.BasicAuth] = None, validate_token: Optional[base.Boolean] = True, parse_mode: typing.Optional[base.String] = None, + disable_web_page_preview: Optional[base.Boolean] = None, timeout: typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]] = None, server: TelegramAPIServer = TELEGRAM_PRODUCTION ): @@ -55,6 +58,8 @@ class BaseBot: :type validate_token: :obj:`bool` :param parse_mode: You can set default parse mode :type parse_mode: :obj:`str` + :param disable_web_page_preview: You can set default disable web page preview parameter + :type disable_web_page_preview: :obj:`bool` :param timeout: Request timeout :type timeout: :obj:`typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]]` :param server: Telegram Bot API Server endpoint. @@ -105,10 +110,11 @@ class BaseBot: self.parse_mode = parse_mode - def get_new_session(self) -> aiohttp.ClientSession: + self.disable_web_page_preview = disable_web_page_preview + + async def get_new_session(self) -> aiohttp.ClientSession: return aiohttp.ClientSession( - connector=self._connector_class(**self._connector_init, loop=self._main_loop), - loop=self._main_loop, + connector=self._connector_class(**self._connector_init), json_serialize=json.dumps ) @@ -116,10 +122,25 @@ class BaseBot: def loop(self) -> Optional[asyncio.AbstractEventLoop]: return self._main_loop - @property - def session(self) -> Optional[aiohttp.ClientSession]: + async def get_session(self) -> Optional[aiohttp.ClientSession]: if self._session is None or self._session.closed: - self._session = self.get_new_session() + self._session = await self.get_new_session() + + if not self._session._loop.is_running(): # NOQA + # Hate `aiohttp` devs because it juggles event-loops and breaks already opened session + # So... when we detect a broken session need to fix it by re-creating it + # @asvetlov, if you read this, please no more juggle event-loop inside aiohttp, it breaks the brain. + await self._session.close() + self._session = await self.get_new_session() + + return self._session + + @property + @deprecated( + reason="Client session should be created inside async function, use `await bot.get_session()` instead", + stacklevel=3, + ) + def session(self) -> Optional[aiohttp.ClientSession]: return self._session @staticmethod @@ -185,7 +206,8 @@ class BaseBot: """ Close all client sessions """ - await self.session.close() + if self._session: + await self._session.close() async def request(self, method: base.String, data: Optional[Dict] = None, @@ -205,35 +227,57 @@ class BaseBot: :rtype: Union[List, Dict] :raise: :obj:`aiogram.exceptions.TelegramApiError` """ - return await api.make_request(self.session, self.server, self.__token, method, data, files, + + return await api.make_request(await self.get_session(), self.server, self.__token, method, data, files, proxy=self.proxy, proxy_auth=self.proxy_auth, timeout=self.timeout, **kwargs) - async def download_file(self, file_path: base.String, - destination: Optional[base.InputFile] = None, - timeout: Optional[base.Integer] = sentinel, - chunk_size: Optional[base.Integer] = 65536, - seek: Optional[base.Boolean] = True) -> Union[io.BytesIO, io.FileIO]: + async def download_file( + self, + file_path: base.String, + destination: Optional[Union[base.InputFile, pathlib.Path]] = None, + timeout: Optional[base.Integer] = sentinel, + chunk_size: Optional[base.Integer] = 65536, + seek: Optional[base.Boolean] = True, + destination_dir: Optional[Union[str, pathlib.Path]] = None, + make_dirs: Optional[base.Boolean] = True, + ) -> Union[io.BytesIO, io.FileIO]: """ - Download file by file_path to destination + Download file by file_path to destination file or directory if You want to automatically create destination (:class:`io.BytesIO`) use default value of destination and handle result of this method. + At most one of these parameters can be used: :param destination:, :param destination_dir: + :param file_path: file path on telegram server (You can get it from :obj:`aiogram.types.File`) :type file_path: :obj:`str` :param destination: filename or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` :param timeout: Integer :param chunk_size: Integer :param seek: Boolean - go to start of file when downloading is finished. + :param destination_dir: directory for saving files + :param make_dirs: Make dirs if not exist :return: destination """ - if destination is None: + if destination and destination_dir: + raise ValueError( + "Use only one of the parameters:destination or destination_dir." + ) + + if destination is None and destination_dir is None: destination = io.BytesIO() + elif destination_dir: + destination = os.path.join(destination_dir, file_path) + + if make_dirs and not isinstance(destination, io.IOBase) and os.path.dirname(destination): + os.makedirs(os.path.dirname(destination), exist_ok=True) + url = self.get_file_url(file_path) dest = destination if isinstance(destination, io.IOBase) else open(destination, 'wb') - async with self.session.get(url, timeout=timeout, proxy=self.proxy, proxy_auth=self.proxy_auth) as response: + session = await self.get_session() + async with session.get(url, timeout=timeout, proxy=self.proxy, proxy_auth=self.proxy_auth) as response: while True: chunk = await response.content.read(chunk_size) if not chunk: @@ -294,5 +338,22 @@ class BaseBot: def parse_mode(self): self.parse_mode = None + @property + def disable_web_page_preview(self): + return getattr(self, '_disable_web_page_preview', None) + + @disable_web_page_preview.setter + def disable_web_page_preview(self, value): + if value is None: + setattr(self, '_disable_web_page_preview', None) + else: + if not isinstance(value, bool): + raise TypeError(f"Disable web page preview must be bool, not {type(value)}") + setattr(self, '_disable_web_page_preview', value) + + @disable_web_page_preview.deleter + def disable_web_page_preview(self): + self.disable_web_page_preview = None + def check_auth_widget(self, data): return check_integrity(self.__token, data) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 435def3e..596c0a53 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1,13 +1,14 @@ from __future__ import annotations import datetime +import pathlib import typing import warnings from .base import BaseBot, api from .. import types from ..types import base -from ..utils.deprecated import deprecated +from ..utils.deprecated import deprecated, removed_argument from ..utils.exceptions import ValidationError from ..utils.mixins import DataMixin, ContextInstanceMixin from ..utils.payload import generate_payload, prepare_arg, prepare_attachment, prepare_file @@ -43,25 +44,37 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): if hasattr(self, '_me'): delattr(self, '_me') - async def download_file_by_id(self, file_id: base.String, destination=None, - timeout: base.Integer = 30, chunk_size: base.Integer = 65536, - seek: base.Boolean = True): + async def download_file_by_id( + self, + file_id: base.String, + destination: typing.Optional[base.InputFile, pathlib.Path] = None, + timeout: base.Integer = 30, + chunk_size: base.Integer = 65536, + seek: base.Boolean = True, + destination_dir: typing.Optional[typing.Union[str, pathlib.Path]] = None, + make_dirs: typing.Optional[base.Boolean] = True, + ): """ - Download file by file_id to destination + Download file by file_id to destination file or directory if You want to automatically create destination (:class:`io.BytesIO`) use default value of destination and handle result of this method. + At most one of these parameters can be used: :param destination:, :param destination_dir: + :param file_id: str :param destination: filename or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` :param timeout: int :param chunk_size: int :param seek: bool - go to start of file when downloading is finished + :param destination_dir: directory for saving files + :param make_dirs: Make dirs if not exist :return: destination """ file = await self.get_file(file_id) return await self.download_file(file_path=file.file_path, destination=destination, - timeout=timeout, chunk_size=chunk_size, seek=seek) + timeout=timeout, chunk_size=chunk_size, seek=seek, + destination_dir=destination_dir, make_dirs=make_dirs) # === Getting updates === # https://core.telegram.org/bots/api#getting-updates @@ -257,6 +270,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): entities: typing.Optional[typing.List[types.MessageEntity]] = None, disable_web_page_preview: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -289,6 +303,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -310,26 +328,44 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) if self.parse_mode and entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.disable_web_page_preview: + payload.setdefault('disable_web_page_preview', self.disable_web_page_preview) result = await self.request(api.Methods.SEND_MESSAGE, payload) return types.Message(**result) - async def forward_message(self, chat_id: typing.Union[base.Integer, base.String], - from_chat_id: typing.Union[base.Integer, base.String], message_id: base.Integer, - disable_notification: typing.Optional[base.Boolean] = None) -> types.Message: + async def forward_message(self, + chat_id: typing.Union[base.Integer, base.String], + from_chat_id: typing.Union[base.Integer, base.String], + message_id: base.Integer, + disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, + ) -> types.Message: """ Use this method to forward messages of any kind. Source: https://core.telegram.org/bots/api#forwardmessage - :param chat_id: Unique identifier for the target chat or username of the target channel + :param chat_id: Unique identifier for the target chat or + username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param from_chat_id: Unique identifier for the chat where the original message was sent + + :param from_chat_id: Unique identifier for the chat where the + original message was sent :type from_chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound + + :param disable_notification: Sends the message silently. Users + will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` - :param message_id: Message identifier in the chat specified in from_chat_id + + :param protect_content: Protects the contents of the forwarded + message from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + + :param message_id: Message identifier in the chat specified in + from_chat_id :type message_id: :obj:`base.Integer` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ @@ -346,6 +382,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -388,6 +425,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -409,7 +450,6 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): reply_markup = prepare_arg(reply_markup) caption_entities = prepare_arg(caption_entities) payload = generate_payload(**locals()) - if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) @@ -423,6 +463,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -455,6 +496,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -493,6 +538,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): title: typing.Optional[base.String] = None, thumb: typing.Union[base.InputFile, base.String, None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -540,6 +586,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -577,6 +627,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, disable_content_type_detection: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -622,6 +673,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -664,12 +719,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, supports_streaming: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, - types.ForceReply, None] = None) -> types.Message: + types.ForceReply, None] = None, + ) -> types.Message: """ Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -711,6 +768,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -750,6 +811,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[typing.Union[types.InlineKeyboardMarkup, @@ -801,6 +863,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -837,6 +903,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): caption_entities: typing.Optional[typing.List[types.MessageEntity]] = None, duration: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -876,6 +943,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -909,12 +980,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): length: typing.Optional[base.Integer] = None, thumb: typing.Union[base.InputFile, base.String, None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, - types.ForceReply, None] = None) -> types.Message: + types.ForceReply, None] = None, + ) -> types.Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. Use this method to send video messages. @@ -939,6 +1012,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -967,6 +1044,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): chat_id: typing.Union[base.Integer, base.String], media: typing.Union[types.MediaGroup, typing.List], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, ) -> typing.List[types.Message]: @@ -990,6 +1068,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the messages are a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -1005,9 +1087,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): if isinstance(media, list): media = types.MediaGroup(media) - # check MediaGroup quantity - if 2 > len(media.media) > 10: - raise ValidationError("Media group must include 2-10 items") + # Check MediaGroup quantity + if not (1 <= len(media.media) <= 10): + raise ValidationError("Media group must include 2-10 items as written in docs, but also it works with 1 element") files = dict(media.get_files()) @@ -1024,12 +1106,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): heading: typing.Optional[base.Integer] = None, proximity_alert_radius: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, - types.ForceReply, None] = None) -> types.Message: + types.ForceReply, None] = None, + ) -> types.Message: """ Use this method to send point on the map. @@ -1063,6 +1147,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -1188,6 +1276,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): google_place_id: typing.Optional[base.String] = None, google_place_type: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -1233,6 +1322,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -1262,12 +1355,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): last_name: typing.Optional[base.String] = None, vcard: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, - types.ForceReply, None] = None) -> types.Message: + types.ForceReply, None] = None, + ) -> types.Message: """ Use this method to send phone contacts. @@ -1291,6 +1386,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -1331,6 +1430,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): None] = None, is_closed: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, @@ -1398,6 +1498,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): a notification with no sound. :type disable_notification: :obj:`typing.Optional[Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[Integer]` @@ -1430,6 +1534,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): async def send_dice(self, chat_id: typing.Union[base.Integer, base.String], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, emoji: typing.Optional[base.String] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, @@ -1458,6 +1563,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -1801,6 +1910,55 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.SET_CHAT_ADMINISTRATOR_CUSTOM_TITLE, payload) + @removed_argument("until_date", "2.19") + async def ban_chat_sender_chat( + self, + chat_id: typing.Union[base.Integer, base.String], + sender_chat_id: base.Integer, + ): + """Ban a channel chat in a supergroup or a channel. + + Until the chat is unbanned, the owner of the banned chat won't + be able to send messages on behalf of any of their channels. + The bot must be an administrator in the supergroup or channel + for this to work and must have the appropriate administrator + rights. Returns True on success. + + Source: https://core.telegram.org/bots/api#banchatsenderchat + + :param chat_id: Unique identifier for the target chat or + username of the target channel (in the format + @channelusername) + :param sender_chat_id: Unique identifier of the target sender + chat + """ + payload = generate_payload(**locals()) + + return await self.request(api.Methods.BAN_CHAT_SENDER_CHAT, payload) + + async def unban_chat_sender_chat( + self, + chat_id: typing.Union[base.Integer, base.String], + sender_chat_id: base.Integer, + ): + """Unban a previously banned channel chat in a supergroup or + channel. + + The bot must be an administrator for this to work and must have + the appropriate administrator rights. Returns True on success. + + Source: https://core.telegram.org/bots/api#unbanchatsenderchat + + :param chat_id: Unique identifier for the target chat or + username of the target channel (in the format + @channelusername) + :param sender_chat_id: Unique identifier of the target sender + chat + """ + payload = generate_payload(**locals()) + + return await self.request(api.Methods.UNBAN_CHAT_SENDER_CHAT, payload) + async def set_chat_permissions(self, chat_id: typing.Union[base.Integer, base.String], permissions: types.ChatPermissions) -> base.Boolean: """ @@ -1840,6 +1998,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): expire_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, member_limit: typing.Optional[base.Integer] = None, + name: typing.Optional[base.String] = None, + creates_join_request: typing.Optional[base.Boolean] = None, ) -> types.ChatInviteLink: """ Use this method to create an additional invite link for a chat. @@ -1861,6 +2021,13 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): simultaneously after joining the chat via this invite link; 1-99999 :type member_limit: :obj:`typing.Optional[base.Integer]` + :param name: Invite link name; 0-32 characters + :type name: :obj:`typing.Optional[base.String]` + + :param creates_join_request: True, if users joining the chat via the link need + to be approved by chat administrators. If True, member_limit can't be specified + :type creates_join_request: :obj:`typing.Optional[base.Boolean]` + :return: the new invite link as ChatInviteLink object. :rtype: :obj:`types.ChatInviteLink` """ @@ -1876,6 +2043,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): expire_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, member_limit: typing.Optional[base.Integer] = None, + name: typing.Optional[base.String] = None, + creates_join_request: typing.Optional[base.Boolean] = None, ) -> types.ChatInviteLink: """ Use this method to edit a non-primary invite link created by the bot. @@ -1899,6 +2068,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): simultaneously after joining the chat via this invite link; 1-99999 :type member_limit: :obj:`typing.Optional[base.Integer]` + :param name: Invite link name; 0-32 characters + :type name: :obj:`typing.Optional[base.String]` + + :param creates_join_request: True, if users joining the chat via the link need + to be approved by chat administrators. If True, member_limit can't be specified + :type creates_join_request: :obj:`typing.Optional[base.Boolean]` + + :return: edited invite link as a ChatInviteLink object. """ expire_date = prepare_arg(expire_date) @@ -1929,6 +2106,59 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.REVOKE_CHAT_INVITE_LINK, payload) return types.ChatInviteLink(**result) + async def approve_chat_join_request(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + ) -> base.Boolean: + """ + Use this method to approve a chat join request. + The bot must be an administrator in the chat for this to work and must have the + can_invite_users administrator right. + + Returns True on success. + + Source: https://core.telegram.org/bots/api#approvechatjoinrequest + + :param chat_id: Unique identifier for the target chat or username of the target channel + (in the format @channelusername) + :type chat_id: typing.Union[base.Integer, base.String] + + :param user_id: Unique identifier of the target user + :type user_id: base.Integer + + :return: + """ + payload = generate_payload(**locals()) + + return await self.request(api.Methods.APPROVE_CHAT_JOIN_REQUEST, payload) + + async def decline_chat_join_request(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + ) -> base.Boolean: + """ + Use this method to decline a chat join request. + The bot must be an administrator in the chat for this to work and + must have the can_invite_users administrator right. + Returns True on success. + + Returns True on success. + + Source: https://core.telegram.org/bots/api#declinechatjoinrequest + + :param chat_id: Unique identifier for the target chat or username of the target channel + (in the format @channelusername) + :type chat_id: typing.Union[base.Integer, base.String] + + :param user_id: Unique identifier of the target user + :type user_id: base.Integer + + :return: + """ + payload = generate_payload(**locals()) + + return await self.request(api.Methods.DECLINE_CHAT_JOIN_REQUEST, payload) + async def set_chat_photo(self, chat_id: typing.Union[base.Integer, base.String], photo: base.InputFile) -> base.Boolean: """ @@ -2129,7 +2359,9 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return types.Chat(**result) async def get_chat_administrators(self, chat_id: typing.Union[base.Integer, base.String] - ) -> typing.List[types.ChatMember]: + ) -> typing.List[ + typing.Union[types.ChatMemberOwner, types.ChatMemberAdministrator]]: + """ Use this method to get a list of administrators in a chat. @@ -2407,6 +2639,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) if self.parse_mode and entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.disable_web_page_preview: + payload.setdefault('disable_web_page_preview', self.disable_web_page_preview) result = await self.request(api.Methods.EDIT_MESSAGE_TEXT, payload) if isinstance(result, bool): @@ -2594,12 +2828,14 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): async def send_sticker(self, chat_id: typing.Union[base.Integer, base.String], sticker: typing.Union[base.InputFile, base.String], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, - types.ForceReply, None] = None) -> types.Message: + types.ForceReply, None] = None, + ) -> types.Message: """ Use this method to send .webp stickers. @@ -2614,6 +2850,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -2910,6 +3150,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): send_email_to_provider: typing.Optional[base.Boolean] = None, is_flexible: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Optional[types.InlineKeyboardMarkup] = None, @@ -3009,6 +3250,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` @@ -3129,6 +3374,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): chat_id: base.Integer, game_short_name: base.String, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Optional[types.InlineKeyboardMarkup] = None, @@ -3148,6 +3394,10 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Optional[base.Integer]` diff --git a/aiogram/contrib/fsm_storage/files.py b/aiogram/contrib/fsm_storage/files.py index 455ca3f0..c28723a9 100644 --- a/aiogram/contrib/fsm_storage/files.py +++ b/aiogram/contrib/fsm_storage/files.py @@ -1,8 +1,8 @@ -import json import pathlib import pickle import typing +from aiogram.utils import json from .memory import MemoryStorage diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 5d0b762c..87d76374 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -5,11 +5,13 @@ This module has redis storage for finite-state machine based on `aioredis aioredis.RedisConnection: + async def redis(self) -> "aioredis.RedisConnection": """ Get Redis connection """ @@ -67,7 +71,6 @@ class RedisStorage(BaseStorage): if self._redis is None or self._redis.closed: self._redis = await aioredis.create_connection((self._host, self._port), db=self._db, password=self._password, ssl=self._ssl, - loop=self._loop, **self._kwargs) return self._redis @@ -204,6 +207,138 @@ class RedisStorage(BaseStorage): await self.set_record(chat=chat, user=user, state=record['state'], data=record_bucket, bucket=bucket) +class AioRedisAdapterBase(ABC): + """Base aioredis adapter class.""" + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: typing.Optional[int] = None, + password: typing.Optional[str] = None, + ssl: typing.Optional[bool] = None, + pool_size: int = 10, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + prefix: str = "fsm", + state_ttl: typing.Optional[int] = None, + data_ttl: typing.Optional[int] = None, + bucket_ttl: typing.Optional[int] = None, + **kwargs, + ): + self._host = host + self._port = port + self._db = db + self._password = password + self._ssl = ssl + self._pool_size = pool_size + self._kwargs = kwargs + self._prefix = (prefix,) + + self._state_ttl = state_ttl + self._data_ttl = data_ttl + self._bucket_ttl = bucket_ttl + + self._redis: typing.Optional["aioredis.Redis"] = None + self._connection_lock = asyncio.Lock() + + @abstractmethod + async def get_redis(self) -> aioredis.Redis: + """Get Redis connection.""" + pass + + async def close(self): + """Grace shutdown.""" + pass + + async def wait_closed(self): + """Wait for grace shutdown finishes.""" + pass + + async def set(self, name, value, ex=None, **kwargs): + """Set the value at key ``name`` to ``value``.""" + if ex == 0: + ex = None + return await self._redis.set(name, value, ex=ex, **kwargs) + + async def get(self, name, **kwargs): + """Return the value at key ``name`` or None.""" + return await self._redis.get(name, **kwargs) + + async def delete(self, *names): + """Delete one or more keys specified by ``names``""" + return await self._redis.delete(*names) + + async def keys(self, pattern, **kwargs): + """Returns a list of keys matching ``pattern``.""" + return await self._redis.keys(pattern, **kwargs) + + async def flushdb(self): + """Delete all keys in the current database.""" + return await self._redis.flushdb() + + +class AioRedisAdapterV1(AioRedisAdapterBase): + """Redis adapter for aioredis v1.""" + + async def get_redis(self) -> aioredis.Redis: + """Get Redis connection.""" + async with self._connection_lock: # to prevent race + if self._redis is None or self._redis.closed: + self._redis = await aioredis.create_redis_pool( + (self._host, self._port), + db=self._db, + password=self._password, + ssl=self._ssl, + minsize=1, + maxsize=self._pool_size, + **self._kwargs, + ) + return self._redis + + async def close(self): + async with self._connection_lock: + if self._redis and not self._redis.closed: + self._redis.close() + + async def wait_closed(self): + async with self._connection_lock: + if self._redis: + return await self._redis.wait_closed() + return True + + async def get(self, name, **kwargs): + return await self._redis.get(name, encoding="utf8", **kwargs) + + async def set(self, name, value, ex=None, **kwargs): + if ex == 0: + ex = None + return await self._redis.set(name, value, expire=ex, **kwargs) + + async def keys(self, pattern, **kwargs): + """Returns a list of keys matching ``pattern``.""" + return await self._redis.keys(pattern, encoding="utf8", **kwargs) + + +class AioRedisAdapterV2(AioRedisAdapterBase): + """Redis adapter for aioredis v2.""" + + async def get_redis(self) -> aioredis.Redis: + """Get Redis connection.""" + async with self._connection_lock: # to prevent race + if self._redis is None: + self._redis = aioredis.Redis( + host=self._host, + port=self._port, + db=self._db, + password=self._password, + ssl=self._ssl, + max_connections=self._pool_size, + decode_responses=True, + **self._kwargs, + ) + return self._redis + + class RedisStorage2(BaseStorage): """ Busted Redis-base storage for FSM. @@ -224,19 +359,28 @@ class RedisStorage2(BaseStorage): await dp.storage.wait_closed() """ - def __init__(self, host: str = 'localhost', port=6379, db=None, password=None, - ssl=None, pool_size=10, loop=None, prefix='fsm', - state_ttl: int = 0, - data_ttl: int = 0, - bucket_ttl: int = 0, - **kwargs): + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: typing.Optional[int] = None, + password: typing.Optional[str] = None, + ssl: typing.Optional[bool] = None, + pool_size: int = 10, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + prefix: str = "fsm", + state_ttl: typing.Optional[int] = None, + data_ttl: typing.Optional[int] = None, + bucket_ttl: typing.Optional[int] = None, + **kwargs, + ): self._host = host self._port = port self._db = db self._password = password self._ssl = ssl self._pool_size = pool_size - self._loop = loop or asyncio.get_event_loop() self._kwargs = kwargs self._prefix = (prefix,) @@ -244,49 +388,62 @@ class RedisStorage2(BaseStorage): self._data_ttl = data_ttl self._bucket_ttl = bucket_ttl - self._redis: typing.Optional[aioredis.RedisConnection] = None - self._connection_lock = asyncio.Lock(loop=self._loop) + self._redis: typing.Optional[AioRedisAdapterBase] = None + self._connection_lock = asyncio.Lock() + @deprecated("This method will be removed in aiogram v3.0. " + "You should use your own instance of Redis.", stacklevel=3) async def redis(self) -> aioredis.Redis: - """ - Get Redis connection - """ - # Use thread-safe asyncio Lock because this method without that is not safe - async with self._connection_lock: - if self._redis is None or self._redis.closed: - self._redis = await aioredis.create_redis_pool((self._host, self._port), - db=self._db, password=self._password, ssl=self._ssl, - minsize=1, maxsize=self._pool_size, - loop=self._loop, **self._kwargs) + adapter = await self._get_adapter() + return await adapter.get_redis() + + async def _get_adapter(self) -> AioRedisAdapterBase: + """Get adapter based on aioredis version.""" + if self._redis is None: + redis_version = int(aioredis.__version__.split(".")[0]) + connection_data = dict( + host=self._host, + port=self._port, + db=self._db, + password=self._password, + ssl=self._ssl, + pool_size=self._pool_size, + **self._kwargs, + ) + if redis_version == 1: + self._redis = AioRedisAdapterV1(**connection_data) + elif redis_version == 2: + self._redis = AioRedisAdapterV2(**connection_data) + else: + raise RuntimeError(f"Unsupported aioredis version: {redis_version}") + await self._redis.get_redis() return self._redis def generate_key(self, *parts): return ':'.join(self._prefix + tuple(map(str, parts))) async def close(self): - async with self._connection_lock: - if self._redis and not self._redis.closed: - self._redis.close() + if self._redis: + return await self._redis.close() async def wait_closed(self): - async with self._connection_lock: - if self._redis: - return await self._redis.wait_closed() - return True + if self._redis: + await self._redis.wait_closed() + self._redis = None async def get_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[str] = None) -> typing.Optional[str]: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_KEY) - redis = await self.redis() - return await redis.get(key, encoding='utf8') or self.resolve_state(default) + redis = await self._get_adapter() + return await redis.get(key) or self.resolve_state(default) async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[dict] = None) -> typing.Dict: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) - redis = await self.redis() - raw_result = await redis.get(key, encoding='utf8') + redis = await self._get_adapter() + raw_result = await redis.get(key) if raw_result: return json.loads(raw_result) return default or {} @@ -295,19 +452,19 @@ class RedisStorage2(BaseStorage): state: typing.Optional[typing.AnyStr] = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_KEY) - redis = await self.redis() + redis = await self._get_adapter() if state is None: await redis.delete(key) else: - await redis.set(key, self.resolve_state(state), expire=self._state_ttl) + await redis.set(key, self.resolve_state(state), ex=self._state_ttl) async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) - redis = await self.redis() + redis = await self._get_adapter() if data: - await redis.set(key, json.dumps(data), expire=self._data_ttl) + await redis.set(key, json.dumps(data), ex=self._data_ttl) else: await redis.delete(key) @@ -326,8 +483,8 @@ class RedisStorage2(BaseStorage): default: typing.Optional[dict] = None) -> typing.Dict: chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) - redis = await self.redis() - raw_result = await redis.get(key, encoding='utf8') + redis = await self._get_adapter() + raw_result = await redis.get(key) if raw_result: return json.loads(raw_result) return default or {} @@ -336,9 +493,9 @@ class RedisStorage2(BaseStorage): bucket: typing.Dict = None): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) - redis = await self.redis() + redis = await self._get_adapter() if bucket: - await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) + await redis.set(key, json.dumps(bucket), ex=self._bucket_ttl) else: await redis.delete(key) @@ -358,13 +515,13 @@ class RedisStorage2(BaseStorage): :param full: clean DB or clean only states :return: """ - conn = await self.redis() + redis = await self._get_adapter() if full: - await conn.flushdb() + await redis.flushdb() else: - keys = await conn.keys(self.generate_key('*')) - await conn.delete(*keys) + keys = await redis.keys(self.generate_key('*')) + await redis.delete(*keys) async def get_states_list(self) -> typing.List[typing.Tuple[str, str]]: """ @@ -372,10 +529,10 @@ class RedisStorage2(BaseStorage): :return: list of tuples where first element is chat id and second is user id """ - conn = await self.redis() + redis = await self._get_adapter() result = [] - keys = await conn.keys(self.generate_key('*', '*', STATE_KEY), encoding='utf8') + keys = await redis.keys(self.generate_key('*', '*', STATE_KEY)) for item in keys: *_, chat, user, _ = item.split(':') result.append((chat, user)) diff --git a/aiogram/contrib/middlewares/environment.py b/aiogram/contrib/middlewares/environment.py index f6ad56dd..976ed886 100644 --- a/aiogram/contrib/middlewares/environment.py +++ b/aiogram/contrib/middlewares/environment.py @@ -16,7 +16,7 @@ class EnvironmentMiddleware(BaseMiddleware): data.update( bot=dp.bot, dispatcher=dp, - loop=dp.loop or asyncio.get_event_loop() + loop=asyncio.get_event_loop() ) if self.context: data.update(self.context) diff --git a/aiogram/contrib/middlewares/fsm.py b/aiogram/contrib/middlewares/fsm.py index e3550a34..d198417f 100644 --- a/aiogram/contrib/middlewares/fsm.py +++ b/aiogram/contrib/middlewares/fsm.py @@ -1,5 +1,4 @@ import copy -import weakref from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware from aiogram.dispatcher.storage import FSMContext @@ -8,10 +7,6 @@ from aiogram.dispatcher.storage import FSMContext class FSMMiddleware(LifetimeControllerMiddleware): skip_patterns = ['error', 'update'] - def __init__(self): - super(FSMMiddleware, self).__init__() - self._proxies = weakref.WeakKeyDictionary() - async def pre_process(self, obj, data, *args): proxy = await FSMSStorageProxy.create(self.manager.dispatcher.current_state()) data['state_data'] = proxy diff --git a/aiogram/contrib/middlewares/logging.py b/aiogram/contrib/middlewares/logging.py index 82c2b50a..92ef7252 100644 --- a/aiogram/contrib/middlewares/logging.py +++ b/aiogram/contrib/middlewares/logging.py @@ -1,6 +1,5 @@ -import time - import logging +import time from aiogram import types from aiogram.dispatcher.middlewares import BaseMiddleware @@ -89,13 +88,15 @@ class LoggingMiddleware(BaseMiddleware): async def on_pre_process_callback_query(self, callback_query: types.CallbackQuery, data: dict): if callback_query.message: + message = callback_query.message text = (f"Received callback query [ID:{callback_query.id}] " f"from user [ID:{callback_query.from_user.id}] " - f"for message [ID:{callback_query.message.message_id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + f"for message [ID:{message.message_id}] " + f"in chat [{message.chat.type}:{message.chat.id}] " + f"with data: {callback_query.data}") - if callback_query.message.from_user: - text += f" originally posted by user [ID:{callback_query.message.from_user.id}]" + if message.from_user: + text = f"{text} originally posted by user [ID:{message.from_user.id}]" self.logger.info(text) @@ -106,14 +107,16 @@ class LoggingMiddleware(BaseMiddleware): async def on_post_process_callback_query(self, callback_query, results, data: dict): if callback_query.message: + message = callback_query.message text = (f"{HANDLED_STR[bool(len(results))]} " f"callback query [ID:{callback_query.id}] " f"from user [ID:{callback_query.from_user.id}] " - f"for message [ID:{callback_query.message.message_id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + f"for message [ID:{message.message_id}] " + f"in chat [{message.chat.type}:{message.chat.id}] " + f"with data: {callback_query.data}") - if callback_query.message.from_user: - text += f" originally posted by user [ID:{callback_query.message.from_user.id}]" + if message.from_user: + text = f"{text} originally posted by user [ID:{message.from_user.id}]" self.logger.info(text) @@ -180,6 +183,16 @@ class LoggingMiddleware(BaseMiddleware): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} chat_member " f"for user [ID:{chat_member_update.from_user.id}]") + async def on_pre_chat_join_request(self, chat_join_request, data): + self.logger.info(f"Received chat join request " + f"for user [ID:{chat_join_request.from_user.id}] " + f"in chat [ID:{chat_join_request.chat.id}]") + + async def on_post_chat_join_request(self, chat_join_request, results, data): + self.logger.debug(f"{HANDLED_STR[bool(len(results))]} chat join request " + f"for user [ID:{chat_join_request.from_user.id}] " + f"in chat [ID:{chat_join_request.chat.id}]") + class LoggingFilter(logging.Filter): """ diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 1e36f202..e6160b3e 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -56,8 +56,6 @@ class Dispatcher(DataMixin, ContextInstanceMixin): filters_factory = FiltersFactory(self) self.bot: Bot = bot - if loop is not None: - _ensure_loop(loop) self._main_loop = loop self.storage = storage self.run_tasks_by_default = run_tasks_by_default @@ -80,6 +78,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.poll_answer_handlers = Handler(self, middleware_key='poll_answer') self.my_chat_member_handlers = Handler(self, middleware_key='my_chat_member') self.chat_member_handlers = Handler(self, middleware_key='chat_member') + self.chat_join_request_handlers = Handler(self, middleware_key='chat_join_request') self.errors_handlers = Handler(self, once=False, middleware_key='error') self.middleware = MiddlewareManager(self) @@ -103,10 +102,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): @property def _close_waiter(self) -> "asyncio.Future": if self._dispatcher_close_waiter is None: - if self._main_loop is not None: - self._dispatcher_close_waiter = self._main_loop.create_future() - else: - self._dispatcher_close_waiter = asyncio.get_event_loop().create_future() + self._dispatcher_close_waiter = asyncio.get_event_loop().create_future() return self._dispatcher_close_waiter def _setup_filters(self): @@ -159,13 +155,14 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.errors_handlers, ]) filters_factory.bind(AdminFilter, event_handlers=[ - self.message_handlers, + self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, - self.callback_query_handlers, + self.callback_query_handlers, self.inline_query_handlers, self.chat_member_handlers, + self.chat_join_request_handlers, ]) filters_factory.bind(IDFilter, event_handlers=[ self.message_handlers, @@ -176,6 +173,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.inline_query_handlers, self.chat_member_handlers, self.my_chat_member_handlers, + self.chat_join_request_handlers, ]) filters_factory.bind(IsReplyFilter, event_handlers=[ self.message_handlers, @@ -202,7 +200,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.edited_channel_post_handlers, self.callback_query_handlers, self.my_chat_member_handlers, - self.chat_member_handlers + self.chat_member_handlers, + self.chat_join_request_handlers, ]) filters_factory.bind(MediaGroupFilter, event_handlers=[ self.message_handlers, @@ -305,6 +304,11 @@ class Dispatcher(DataMixin, ContextInstanceMixin): types.ChatMemberUpdated.set_current(update.chat_member) types.User.set_current(update.chat_member.from_user) return await self.chat_member_handlers.notify(update.chat_member) + if update.chat_join_request: + types.ChatJoinRequest.set_current(update.chat_join_request) + types.Chat.set_current(update.chat_join_request.chat) + types.User.set_current(update.chat_join_request.from_user) + return await self.chat_join_request_handlers.notify(update.chat_join_request) except Exception as e: err = await self.errors_handlers.notify(update, e) if err: @@ -326,10 +330,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): return await self.bot.delete_webhook() def _loop_create_task(self, coro): - if self._main_loop is None: - return asyncio.create_task(coro) - _ensure_loop(self._main_loop) - return self._main_loop.create_task(coro) + return asyncio.create_task(coro) async def start_polling(self, timeout=20, @@ -394,7 +395,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): log.debug(f"Received {len(updates)} updates.") offset = updates[-1].update_id + 1 - self._loop_create_task(self._process_polling_updates(updates, fast)) + asyncio.create_task(self._process_polling_updates(updates, fast)) if relax: await asyncio.sleep(relax) @@ -980,14 +981,14 @@ class Dispatcher(DataMixin, ContextInstanceMixin): :param run_task: run callback in task (no wait results) :param kwargs: """ - + def decorator(callback): self.register_poll_handler(callback, *custom_filters, run_task=run_task, **kwargs) return callback return decorator - + def register_poll_answer_handler(self, callback, *custom_filters, run_task=None, **kwargs): """ Register handler for poll_answer @@ -1007,7 +1008,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): *custom_filters, **kwargs) self.poll_answer_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - + def poll_answer_handler(self, *custom_filters, run_task=None, **kwargs): """ Decorator for poll_answer handler @@ -1026,7 +1027,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): def decorator(callback): self.register_poll_answer_handler(callback, *custom_filters, run_task=run_task, - **kwargs) + **kwargs) return callback return decorator @@ -1143,6 +1144,62 @@ class Dispatcher(DataMixin, ContextInstanceMixin): return decorator + def register_chat_join_request_handler(self, + callback: typing.Callable, + *custom_filters, + run_task: typing.Optional[bool] = None, + **kwargs) -> None: + """ + Register handler for chat_join_request + + Example: + + .. code-block:: python3 + + dp.register_chat_join_request(some_chat_join_request) + + :param callback: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + filters_set = self.filters_factory.resolve( + self.chat_join_request_handlers, + *custom_filters, + **kwargs, + ) + self.chat_join_request_handlers.register( + handler=self._wrap_async_task(callback, run_task), + filters=filters_set, + ) + + def chat_join_request_handler(self, *custom_filters, run_task=None, **kwargs): + """ + Decorator for chat_join_request handler + + Example: + + .. code-block:: python3 + + @dp.chat_join_request() + async def some_handler(chat_member: types.ChatJoinRequest) + + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + + def decorator(callback): + self.register_chat_join_request_handler( + callback, + *custom_filters, + run_task=run_task, + **kwargs, + ) + return callback + + return decorator + def register_errors_handler(self, callback, *custom_filters, exception=None, run_task=None, **kwargs): """ Register handler for errors @@ -1336,15 +1393,15 @@ class Dispatcher(DataMixin, ContextInstanceMixin): try: response = task.result() except Exception as e: - self._loop_create_task( + asyncio.create_task( self.errors_handlers.notify(types.Update.get_current(), e)) else: if isinstance(response, BaseResponse): - self._loop_create_task(response.execute_response(self.bot)) + asyncio.create_task(response.execute_response(self.bot)) @functools.wraps(func) async def wrapper(*args, **kwargs): - task = self._loop_create_task(func(*args, **kwargs)) + task = asyncio.create_task(func(*args, **kwargs)) task.add_done_callback(process_response) return wrapper @@ -1382,6 +1439,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): :param chat_id: chat id :return: decorator """ + def decorator(func): @functools.wraps(func) async def wrapped(*args, **kwargs): @@ -1411,6 +1469,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): asyncio.get_running_loop().run_in_executor( None, partial_func ) + return wrapped return decorator diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 7a21ca3f..ebd38f08 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -4,7 +4,7 @@ import typing import warnings from contextvars import ContextVar from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, Optional, Union from babel.support import LazyProxy diff --git a/aiogram/dispatcher/storage.py b/aiogram/dispatcher/storage.py index eb248e34..63ce25b2 100644 --- a/aiogram/dispatcher/storage.py +++ b/aiogram/dispatcher/storage.py @@ -408,6 +408,8 @@ class FSMContextProxy: def update(self, data=None, **kwargs): self._check_closed() + if data is None: + data = {} self._data.update(data, **kwargs) def pop(self, key, default=None): @@ -461,7 +463,6 @@ class DisabledStorage(BaseStorage): """ Empty storage. Use it if you don't want to use Finite-State Machine """ - async def close(self): pass @@ -499,6 +500,25 @@ class DisabledStorage(BaseStorage): data: typing.Dict = None): self._warn() + async def get_bucket(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None, + default: typing.Optional[dict] = None) -> typing.Dict: + self._warn() + return {} + + async def set_bucket(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None, + bucket: typing.Dict = None): + self._warn() + + async def update_bucket(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None, + bucket: typing.Dict = None, **kwargs): + self._warn() + @staticmethod def _warn(): warn(f"You haven’t set any storage yet so no states and no data will be saved. \n" diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index c76ffae2..92e475ff 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -74,7 +74,7 @@ allow_ip(TELEGRAM_SUBNET_1, TELEGRAM_SUBNET_2) class WebhookRequestHandler(web.View): """ - Simple Wehhook request handler for aiohttp web server. + Simple Webhook request handler for aiohttp web server. You need to register that in app: @@ -145,7 +145,7 @@ class WebhookRequestHandler(web.View): web_response = web.Response(text='ok') if self.request.app.get('RETRY_AFTER', None): - web_response.headers['Retry-After'] = self.request.app['RETRY_AFTER'] + web_response.headers['Retry-After'] = str(self.request.app['RETRY_AFTER']) return web_response @@ -168,14 +168,14 @@ class WebhookRequestHandler(web.View): :return: """ dispatcher = self.get_dispatcher() - loop = dispatcher.loop or asyncio.get_event_loop() + loop = asyncio.get_event_loop() # Analog of `asyncio.wait_for` but without cancelling task waiter = loop.create_future() timeout_handle = loop.call_later(RESPONSE_TIMEOUT, asyncio.tasks._release_waiter, waiter) cb = functools.partial(asyncio.tasks._release_waiter, waiter) - fut = asyncio.ensure_future(dispatcher.updates_handler.notify(update), loop=loop) + fut = asyncio.ensure_future(dispatcher.updates_handler.notify(update)) fut.add_done_callback(cb) try: @@ -207,7 +207,7 @@ class WebhookRequestHandler(web.View): TimeoutWarning) dispatcher = self.get_dispatcher() - loop = dispatcher.loop or asyncio.get_event_loop() + loop = asyncio.get_running_loop() try: results = task.result() @@ -217,7 +217,7 @@ class WebhookRequestHandler(web.View): else: response = self.get_response(results) if response is not None: - asyncio.ensure_future(response.execute_response(dispatcher.bot), loop=loop) + asyncio.ensure_future(response.execute_response(dispatcher.bot)) def get_response(self, results): """ @@ -241,6 +241,8 @@ class WebhookRequestHandler(web.View): # For reverse proxy (nginx) forwarded_for = self.request.headers.get('X-Forwarded-For', None) if forwarded_for: + # get the left-most ip when there is multiple ips (request got through multiple proxy/load balancers) + forwarded_for = forwarded_for.split(",")[0] return forwarded_for, _check_ip(forwarded_for) # For default method @@ -433,6 +435,18 @@ class DisableWebPagePreviewMixin: setattr(self, 'disable_web_page_preview', True) return self + @staticmethod + def _global_disable_web_page_preview(): + """ + Detect global disable web page preview value + + :return: + """ + from aiogram import Bot + bot = Bot.get_current() + if bot is not None: + return bot.disable_web_page_preview + class ParseModeMixin: def as_html(self): @@ -504,6 +518,8 @@ class SendMessage(BaseResponse, ReplyToMixin, ParseModeMixin, DisableNotificatio text = '' if parse_mode is None: parse_mode = self._global_parse_mode() + if disable_web_page_preview is None: + disable_web_page_preview = self._global_disable_web_page_preview() self.chat_id = chat_id self.text = text @@ -1589,6 +1605,8 @@ class EditMessageText(BaseResponse, ParseModeMixin, DisableWebPagePreviewMixin): """ if parse_mode is None: parse_mode = self._global_parse_mode() + if disable_web_page_preview is None: + disable_web_page_preview = self._global_disable_web_page_preview() self.chat_id = chat_id self.message_id = message_id diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 1b289698..9378b32b 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -12,6 +12,7 @@ from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat, ChatActions, ChatType from .chat_invite_link import ChatInviteLink +from .chat_join_request import ChatJoinRequest from .chat_location import ChatLocation from .chat_member import ChatMember, ChatMemberAdministrator, ChatMemberBanned, \ ChatMemberLeft, ChatMemberMember, ChatMemberOwner, ChatMemberRestricted, \ @@ -102,6 +103,7 @@ __all__ = ( 'Chat', 'ChatActions', 'ChatInviteLink', + 'ChatJoinRequest', 'ChatLocation', 'ChatMember', 'ChatMemberStatus', diff --git a/aiogram/types/base.py b/aiogram/types/base.py index 5ef774dd..5bb29472 100644 --- a/aiogram/types/base.py +++ b/aiogram/types/base.py @@ -78,7 +78,7 @@ class TelegramObject(ContextInstanceMixin, metaclass=MetaTelegramObject): Abstract class for telegram objects """ - def __init__(self, conf: typing.Dict[str, typing.Any]=None, **kwargs: typing.Any) -> None: + def __init__(self, conf: typing.Dict[str, typing.Any] = None, **kwargs: typing.Any) -> None: """ Deserialize object @@ -211,6 +211,15 @@ class TelegramObject(ContextInstanceMixin, metaclass=MetaTelegramObject): """ return self.as_json() + def __repr__(self) -> str: + """ + Return object readable representation. + + Example: + :return: object class name and object data as a string + """ + return f"<{type(self).__name__} {self}>" + def __getitem__(self, item: typing.Union[str, int]) -> typing.Any: """ Item getter (by key) diff --git a/aiogram/types/bot_command.py b/aiogram/types/bot_command.py index 39e38e4f..996d1735 100644 --- a/aiogram/types/bot_command.py +++ b/aiogram/types/bot_command.py @@ -12,4 +12,4 @@ class BotCommand(base.TelegramObject): description: base.String = fields.Field() def __init__(self, command: base.String, description: base.String): - super(BotCommand, self).__init__(command=command, description=description) \ No newline at end of file + super(BotCommand, self).__init__(command=command, description=description) diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 2cd19a0f..c18ad88b 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -7,12 +7,12 @@ import typing from . import base, fields from .chat_invite_link import ChatInviteLink from .chat_location import ChatLocation -from .chat_member import ChatMember +from .chat_member import ChatMember, ChatMemberAdministrator, ChatMemberOwner from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto from .input_file import InputFile from ..utils import helper, markdown -from ..utils.deprecated import deprecated, DeprecatedReadOnlyClassVar +from ..utils.deprecated import deprecated, DeprecatedReadOnlyClassVar, removed_argument class Chat(base.TelegramObject): @@ -30,12 +30,14 @@ class Chat(base.TelegramObject): all_members_are_administrators: base.Boolean = fields.Field() photo: ChatPhoto = fields.Field(base=ChatPhoto) bio: base.String = fields.Field() + has_private_forwards: base.Boolean = fields.Field() description: base.String = fields.Field() invite_link: base.String = fields.Field() pinned_message: 'Message' = fields.Field(base='Message') permissions: ChatPermissions = fields.Field(base=ChatPermissions) slow_mode_delay: base.Integer = fields.Field() message_auto_delete_time: base.Integer = fields.Field() + has_protected_content: base.Boolean = fields.Field() sticker_set_name: base.String = fields.Field() can_set_sticker_set: base.Boolean = fields.Field() linked_chat_id: base.Integer = fields.Field() @@ -470,7 +472,7 @@ class Chat(base.TelegramObject): """ return await self.bot.leave_chat(self.id) - async def get_administrators(self) -> typing.List[ChatMember]: + async def get_administrators(self) -> typing.List[typing.Union[ChatMemberOwner, ChatMemberAdministrator]]: """ Use this method to get a list of administrators in a chat. @@ -480,7 +482,7 @@ class Chat(base.TelegramObject): chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. - :rtype: :obj:`typing.List[types.ChatMember]` + :rtype: :obj:`typing.List[typing.Union[types.ChatMemberOwner, types.ChatMemberAdministrator]]` """ return await self.bot.get_chat_administrators(self.id) @@ -497,7 +499,7 @@ class Chat(base.TelegramObject): async def get_members_count(self) -> base.Integer: """Renamed to get_member_count.""" - return await self.get_member_count(self.id) + return await self.get_member_count() async def get_member(self, user_id: base.Integer) -> ChatMember: """ @@ -621,6 +623,27 @@ class Chat(base.TelegramObject): message_id=message_id, ) + @removed_argument("until_date", "2.19") + async def ban_sender_chat( + self, + sender_chat_id: base.Integer, + ): + """Shortcut for banChatSenderChat method.""" + return await self.bot.ban_chat_sender_chat( + chat_id=self.id, + sender_chat_id=sender_chat_id, + ) + + async def unban_sender_chat( + self, + sender_chat_id: base.Integer, + ): + """Shortcut for unbanChatSenderChat method.""" + return await self.bot.unban_chat_sender_chat( + chat_id=self.id, + sender_chat_id=sender_chat_id, + ) + def __int__(self): return self.id @@ -742,6 +765,7 @@ class ChatActions(helper.Helper): FIND_LOCATION: str = helper.Item() # find_location RECORD_VIDEO_NOTE: str = helper.Item() # record_video_note UPLOAD_VIDEO_NOTE: str = helper.Item() # upload_video_note + CHOOSE_STICKER: str = helper.Item() # choose_sticker @classmethod async def _do(cls, action: str, sleep=None): @@ -882,3 +906,13 @@ class ChatActions(helper.Helper): :return: """ await cls._do(cls.UPLOAD_VIDEO_NOTE, sleep) + + @classmethod + async def choose_sticker(cls, sleep=None): + """ + Do choose sticker + + :param sleep: sleep timeout + :return: + """ + await cls._do(cls.CHOOSE_STICKER, sleep) diff --git a/aiogram/types/chat_invite_link.py b/aiogram/types/chat_invite_link.py index 55794780..46d505e8 100644 --- a/aiogram/types/chat_invite_link.py +++ b/aiogram/types/chat_invite_link.py @@ -16,5 +16,8 @@ class ChatInviteLink(base.TelegramObject): creator: User = fields.Field(base=User) is_primary: base.Boolean = fields.Field() is_revoked: base.Boolean = fields.Field() + name: base.String = fields.Field() expire_date: datetime = fields.DateTimeField() member_limit: base.Integer = fields.Field() + creates_join_request: datetime = fields.DateTimeField() + pending_join_request_count: base.Integer = fields.Field() diff --git a/aiogram/types/chat_join_request.py b/aiogram/types/chat_join_request.py new file mode 100644 index 00000000..71ee964a --- /dev/null +++ b/aiogram/types/chat_join_request.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from . import base +from . import fields +from .chat import Chat +from .chat_invite_link import ChatInviteLink +from .user import User + + +class ChatJoinRequest(base.TelegramObject): + """ + Represents a join request sent to a chat. + + https://core.telegram.org/bots/api#chatinvitelink + """ + + chat: Chat = fields.Field(base=Chat) + from_user: User = fields.Field(alias="from", base=User) + date: datetime = fields.DateTimeField() + bio: base.String = fields.Field() + invite_link: ChatInviteLink = fields.Field(base=ChatInviteLink) + + async def approve(self) -> base.Boolean: + return await self.bot.approve_chat_join_request( + chat_id=self.chat.id, + user_id=self.from_user.id, + ) + + async def decline(self) -> base.Boolean: + return await self.bot.decline_chat_join_request( + chat_id=self.chat.id, + user_id=self.from_user.id, + ) diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 372b3468..ecbf9d2c 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,6 +1,5 @@ import datetime import typing -from typing import Optional from . import base, fields from .user import User @@ -29,6 +28,8 @@ class ChatMemberStatus(helper.Helper): def is_chat_creator(cls, role: str) -> bool: return role == cls.CREATOR + is_chat_owner = is_chat_creator + @classmethod def is_chat_admin(cls, role: str) -> bool: return role in (cls.ADMINISTRATOR, cls.CREATOR) @@ -38,7 +39,7 @@ class ChatMemberStatus(helper.Helper): return role in (cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR, cls.RESTRICTED) @classmethod - def get_class_by_status(cls, status: str) -> Optional["ChatMember"]: + def get_class_by_status(cls, status: str) -> typing.Optional[typing.Type["ChatMember"]]: return { cls.OWNER: ChatMemberOwner, cls.ADMINISTRATOR: ChatMemberAdministrator, @@ -69,7 +70,9 @@ class ChatMember(base.TelegramObject): return self.user.id @classmethod - def resolve(cls, **kwargs) -> "ChatMember": + def resolve(cls, **kwargs) -> typing.Union["ChatMemberOwner", "ChatMemberAdministrator", + "ChatMemberMember", "ChatMemberRestricted", + "ChatMemberLeft", "ChatMemberBanned"]: status = kwargs.get("status") mapping = { ChatMemberStatus.OWNER: ChatMemberOwner, @@ -89,12 +92,16 @@ class ChatMember(base.TelegramObject): def to_object(cls, data: typing.Dict[str, typing.Any], conf: typing.Dict[str, typing.Any] = None - ) -> "ChatMember": - return cls.resolve(**data) + ) -> typing.Union["ChatMemberOwner", "ChatMemberAdministrator", + "ChatMemberMember", "ChatMemberRestricted", + "ChatMemberLeft", "ChatMemberBanned"]: + return cls.resolve(conf=conf, **data) def is_chat_creator(self) -> bool: return ChatMemberStatus.is_chat_creator(self.status) + is_chat_owner = is_chat_creator + def is_chat_admin(self) -> bool: return ChatMemberStatus.is_chat_admin(self.status) @@ -113,6 +120,22 @@ class ChatMemberOwner(ChatMember): custom_title: base.String = fields.Field() is_anonymous: base.Boolean = fields.Field() + # Next fields cannot be received from API but + # it useful for compatibility and cleaner code: + # >>> if member.is_admin() and member.can_promote_members: + # >>> await message.reply('You can promote me') + can_be_edited: base.Boolean = fields.ConstField(False) + can_manage_chat: base.Boolean = fields.ConstField(True) + can_post_messages: base.Boolean = fields.ConstField(True) + can_edit_messages: base.Boolean = fields.ConstField(True) + can_delete_messages: base.Boolean = fields.ConstField(True) + can_manage_voice_chats: base.Boolean = fields.ConstField(True) + can_restrict_members: base.Boolean = fields.ConstField(True) + can_promote_members: base.Boolean = fields.ConstField(True) + can_change_info: base.Boolean = fields.ConstField(True) + can_invite_users: base.Boolean = fields.ConstField(True) + can_pin_messages: base.Boolean = fields.ConstField(True) + class ChatMemberAdministrator(ChatMember): """ diff --git a/aiogram/types/fields.py b/aiogram/types/fields.py index f898fc62..d7a1d8ca 100644 --- a/aiogram/types/fields.py +++ b/aiogram/types/fields.py @@ -2,7 +2,7 @@ import abc import datetime import weakref -__all__ = ('BaseField', 'Field', 'ListField', 'DateTimeField', 'TextField', 'ListOfLists') +__all__ = ('BaseField', 'Field', 'ListField', 'DateTimeField', 'TextField', 'ListOfLists', 'ConstField') class BaseField(metaclass=abc.ABCMeta): @@ -118,7 +118,7 @@ class Field(BaseField): class ListField(Field): """ - Field contains list ob objects + The field contains a list of objects """ def __init__(self, *args, **kwargs): @@ -162,7 +162,7 @@ class ListOfLists(Field): class DateTimeField(Field): """ - In this field st_ored datetime + In this field stored datetime in: unixtime out: datetime @@ -192,5 +192,13 @@ class TextField(Field): def deserialize(self, value, parent=None): if value is not None and not isinstance(value, str): - raise TypeError(f"Field '{self.alias}' should be str not {type(value).__name__}") + raise TypeError(f"Field {self.alias!r} should be str not {type(value).__name__!r}") return value + + +class ConstField(Field): + def __init__(self, default=None, **kwargs): + super(ConstField, self).__init__(default=default, **kwargs) + + def __set__(self, instance, value): + raise TypeError(f"Field {self.alias!r} is not mutable") diff --git a/aiogram/types/input_file.py b/aiogram/types/input_file.py index c974025a..09ebcfa9 100644 --- a/aiogram/types/input_file.py +++ b/aiogram/types/input_file.py @@ -5,7 +5,7 @@ import logging import os import secrets from pathlib import Path -from typing import Union +from typing import Union, Optional import aiohttp @@ -27,7 +27,7 @@ class InputFile(base.TelegramObject): https://core.telegram.org/bots/api#inputfile """ - def __init__(self, path_or_bytesio: Union[str, io.IOBase, Path], filename=None, conf=None): + def __init__(self, path_or_bytesio: Union[str, io.IOBase, Path, '_WebPipe'], filename=None, conf=None): """ :param path_or_bytesio: @@ -118,7 +118,7 @@ class InputFile(base.TelegramObject): if filename is None: filename = pipe.name - return cls(pipe, filename, chunk_size) + return cls(pipe, filename) def save(self, filename, chunk_size=CHUNK_SIZE): """ @@ -159,8 +159,8 @@ class _WebPipe: self.url = url self.chunk_size = chunk_size - self._session: aiohttp.ClientSession = None - self._response: aiohttp.ClientResponse = None + self._session: Optional[aiohttp.ClientSession] = None + self._response: Optional[aiohttp.ClientResponse] = None self._reader = None self._name = None @@ -182,7 +182,7 @@ class _WebPipe: async def close(self): if self._response and not self._response.closed: - await self._response.close() + self._response.close() if self._session and not self._session.closed: await self._session.close() if self._lock.locked(): diff --git a/aiogram/types/input_message_content.py b/aiogram/types/input_message_content.py index f0c452cd..8406bd05 100644 --- a/aiogram/types/input_message_content.py +++ b/aiogram/types/input_message_content.py @@ -154,6 +154,12 @@ class InputTextMessageContent(InputMessageContent): except RuntimeError: pass + def safe_get_disable_web_page_preview(self): + try: + return self.bot.disable_web_page_preview + except RuntimeError: + pass + def __init__( self, message_text: base.String, @@ -163,6 +169,8 @@ class InputTextMessageContent(InputMessageContent): ): if parse_mode is None: parse_mode = self.safe_get_parse_mode() + if disable_web_page_preview is None: + disable_web_page_preview = self.safe_get_disable_web_page_preview() super().__init__( message_text=message_text, diff --git a/aiogram/types/message.py b/aiogram/types/message.py index c95b14b1..10ef8776 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -58,9 +58,11 @@ class Message(base.TelegramObject): forward_from_message_id: base.Integer = fields.Field() forward_signature: base.String = fields.Field() forward_date: datetime.datetime = fields.DateTimeField() + is_automatic_forward: base.Boolean = fields.Field() reply_to_message: Message = fields.Field(base="Message") via_bot: User = fields.Field(base=User) edit_date: datetime.datetime = fields.DateTimeField() + has_protected_content: base.Boolean = fields.Field() media_group_id: base.String = fields.Field() author_signature: base.String = fields.Field() forward_sender_name: base.String = fields.Field() @@ -313,6 +315,7 @@ class Message(base.TelegramObject): entities: typing.Optional[typing.List[MessageEntity]] = None, disable_web_page_preview: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -343,6 +346,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -365,6 +372,7 @@ class Message(base.TelegramObject): entities=entities, disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -377,6 +385,7 @@ class Message(base.TelegramObject): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -409,6 +418,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -431,6 +444,7 @@ class Message(base.TelegramObject): parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -447,6 +461,7 @@ class Message(base.TelegramObject): title: typing.Optional[base.String] = None, thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -495,6 +510,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -521,6 +540,7 @@ class Message(base.TelegramObject): title=title, thumb=thumb, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -537,6 +557,7 @@ class Message(base.TelegramObject): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -587,6 +608,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -613,6 +638,7 @@ class Message(base.TelegramObject): parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -627,6 +653,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_content_type_detection: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -670,6 +697,10 @@ class Message(base.TelegramObject): notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -696,6 +727,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, disable_content_type_detection=disable_content_type_detection, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -713,6 +745,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, supports_streaming: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -762,6 +795,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -789,6 +826,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, supports_streaming=supports_streaming, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -802,6 +840,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, duration: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -841,6 +880,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -864,6 +907,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, duration=duration, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -876,6 +920,7 @@ class Message(base.TelegramObject): length: typing.Optional[base.Integer] = None, thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -908,6 +953,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -930,6 +979,7 @@ class Message(base.TelegramObject): length=length, thumb=thumb, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -939,6 +989,7 @@ class Message(base.TelegramObject): self, media: typing.Union[MediaGroup, typing.List], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply: base.Boolean = False, ) -> typing.List[Message]: @@ -957,6 +1008,10 @@ class Message(base.TelegramObject): a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -971,6 +1026,7 @@ class Message(base.TelegramObject): self.chat.id, media=media, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, ) @@ -981,6 +1037,7 @@ class Message(base.TelegramObject): longitude: base.Float, live_period: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, horizontal_accuracy: typing.Optional[base.Float] = None, heading: typing.Optional[base.Integer] = None, @@ -1024,6 +1081,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1048,6 +1109,7 @@ class Message(base.TelegramObject): heading=heading, proximity_alert_radius=proximity_alert_radius, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1064,6 +1126,7 @@ class Message(base.TelegramObject): google_place_id: typing.Optional[base.String] = None, google_place_type: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1108,6 +1171,10 @@ class Message(base.TelegramObject): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1136,6 +1203,7 @@ class Message(base.TelegramObject): google_place_id=google_place_id, google_place_type=google_place_type, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1147,6 +1215,7 @@ class Message(base.TelegramObject): first_name: base.String, last_name: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1174,6 +1243,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1195,6 +1268,7 @@ class Message(base.TelegramObject): first_name=first_name, last_name=last_name, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1204,6 +1278,7 @@ class Message(base.TelegramObject): self, sticker: typing.Union[base.InputFile, base.String], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1225,6 +1300,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1244,6 +1323,7 @@ class Message(base.TelegramObject): chat_id=self.chat.id, sticker=sticker, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1264,6 +1344,7 @@ class Message(base.TelegramObject): close_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, is_closed: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1330,6 +1411,10 @@ class Message(base.TelegramObject): a notification with no sound. :type disable_notification: :obj:`typing.Optional[Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1362,6 +1447,7 @@ class Message(base.TelegramObject): close_date=close_date, is_closed=is_closed, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1371,6 +1457,7 @@ class Message(base.TelegramObject): self, emoji: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1397,6 +1484,10 @@ class Message(base.TelegramObject): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1418,6 +1509,7 @@ class Message(base.TelegramObject): chat_id=self.chat.id, emoji=emoji, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1454,6 +1546,7 @@ class Message(base.TelegramObject): entities: typing.Optional[typing.List[MessageEntity]] = None, disable_web_page_preview: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1484,6 +1577,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1506,6 +1603,7 @@ class Message(base.TelegramObject): entities=entities, disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1518,6 +1616,7 @@ class Message(base.TelegramObject): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1550,6 +1649,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1572,6 +1675,7 @@ class Message(base.TelegramObject): parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1588,6 +1692,7 @@ class Message(base.TelegramObject): title: typing.Optional[base.String] = None, thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1636,6 +1741,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1662,6 +1771,7 @@ class Message(base.TelegramObject): title=title, thumb=thumb, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1678,6 +1788,7 @@ class Message(base.TelegramObject): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1728,6 +1839,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1754,6 +1869,7 @@ class Message(base.TelegramObject): parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1768,6 +1884,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_content_type_detection: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1811,6 +1928,10 @@ class Message(base.TelegramObject): notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1837,6 +1958,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, disable_content_type_detection=disable_content_type_detection, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1854,6 +1976,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, supports_streaming: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1903,6 +2026,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -1930,6 +2057,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, supports_streaming=supports_streaming, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -1943,6 +2071,7 @@ class Message(base.TelegramObject): caption_entities: typing.Optional[typing.List[MessageEntity]] = None, duration: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -1982,6 +2111,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2005,6 +2138,7 @@ class Message(base.TelegramObject): caption_entities=caption_entities, duration=duration, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2017,6 +2151,7 @@ class Message(base.TelegramObject): length: typing.Optional[base.Integer] = None, thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2049,6 +2184,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2071,6 +2210,7 @@ class Message(base.TelegramObject): length=length, thumb=thumb, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2080,6 +2220,7 @@ class Message(base.TelegramObject): self, media: typing.Union[MediaGroup, typing.List], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply: base.Boolean = True, ) -> typing.List[Message]: @@ -2098,6 +2239,10 @@ class Message(base.TelegramObject): a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2112,6 +2257,7 @@ class Message(base.TelegramObject): self.chat.id, media=media, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, ) @@ -2122,6 +2268,7 @@ class Message(base.TelegramObject): longitude: base.Float, live_period: typing.Optional[base.Integer] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, horizontal_accuracy: typing.Optional[base.Float] = None, heading: typing.Optional[base.Integer] = None, proximity_alert_radius: typing.Optional[base.Integer] = None, @@ -2164,6 +2311,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, @@ -2184,6 +2335,7 @@ class Message(base.TelegramObject): heading=heading, proximity_alert_radius=proximity_alert_radius, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup, ) @@ -2199,6 +2351,7 @@ class Message(base.TelegramObject): google_place_id: typing.Optional[base.String] = None, google_place_type: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2243,6 +2396,10 @@ class Message(base.TelegramObject): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2271,6 +2428,7 @@ class Message(base.TelegramObject): google_place_id=google_place_id, google_place_type=google_place_type, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2282,6 +2440,7 @@ class Message(base.TelegramObject): first_name: base.String, last_name: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2309,6 +2468,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2330,6 +2493,7 @@ class Message(base.TelegramObject): first_name=first_name, last_name=last_name, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2350,6 +2514,7 @@ class Message(base.TelegramObject): close_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None, is_closed: typing.Optional[base.Boolean] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2416,6 +2581,10 @@ class Message(base.TelegramObject): a notification with no sound. :type disable_notification: :obj:`typing.Optional[Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2448,6 +2617,7 @@ class Message(base.TelegramObject): close_date=close_date, is_closed=is_closed, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2457,6 +2627,7 @@ class Message(base.TelegramObject): self, sticker: typing.Union[base.InputFile, base.String], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2478,6 +2649,10 @@ class Message(base.TelegramObject): :param disable_notification: Sends the message silently. Users will receive a notification with no sound. :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2497,6 +2672,7 @@ class Message(base.TelegramObject): chat_id=self.chat.id, sticker=sticker, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2506,6 +2682,7 @@ class Message(base.TelegramObject): self, emoji: typing.Optional[base.String] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[ InlineKeyboardMarkup, @@ -2532,6 +2709,10 @@ class Message(base.TelegramObject): a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :param allow_sending_without_reply: Pass True, if the message should be sent even if the specified replied-to message is not found :type allow_sending_without_reply: :obj:`typing.Optional[base.Boolean]` @@ -2553,6 +2734,7 @@ class Message(base.TelegramObject): chat_id=self.chat.id, emoji=emoji, disable_notification=disable_notification, + protect_content=protect_content, reply_to_message_id=self.message_id if reply else None, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, @@ -2562,6 +2744,7 @@ class Message(base.TelegramObject): self, chat_id: typing.Union[base.Integer, base.String], disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, ) -> Message: """ Forward this message @@ -2570,13 +2753,23 @@ class Message(base.TelegramObject): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Optional[base.Boolean]` + + :param protect_content: Protects the contents of the forwarded + message from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ return await self.bot.forward_message( - chat_id, self.chat.id, self.message_id, disable_notification + chat_id=chat_id, + from_chat_id=self.chat.id, + message_id=self.message_id, + disable_notification=disable_notification, + protect_content=protect_content, ) async def edit_text( @@ -2793,7 +2986,8 @@ class Message(base.TelegramObject): return await self.bot.delete_message(self.chat.id, self.message_id) async def pin( - self, disable_notification: typing.Optional[base.Boolean] = None, + self, + disable_notification: typing.Optional[base.Boolean] = None, ) -> base.Boolean: """ Use this method to add a message to the list of pinned messages in a chat. @@ -2811,7 +3005,10 @@ class Message(base.TelegramObject): :return: Returns True on success :rtype: :obj:`base.Boolean` """ - return await self.chat.pin_message(self.message_id, disable_notification) + return await self.chat.pin_message( + message_id=self.message_id, + disable_notification=disable_notification, + ) async def unpin(self) -> base.Boolean: """ @@ -2834,6 +3031,7 @@ class Message(base.TelegramObject): self: Message, chat_id: typing.Union[str, int], disable_notification: typing.Optional[bool] = None, + protect_content: typing.Optional[base.Boolean] = None, disable_web_page_preview: typing.Optional[bool] = None, reply_to_message_id: typing.Optional[int] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, @@ -2846,6 +3044,7 @@ class Message(base.TelegramObject): :param chat_id: :param disable_notification: + :param protect_content: :param disable_web_page_preview: for text messages only :param reply_to_message_id: :param allow_sending_without_reply: @@ -2858,6 +3057,7 @@ class Message(base.TelegramObject): "reply_markup": reply_markup or self.reply_markup, "parse_mode": ParseMode.HTML, "disable_notification": disable_notification, + "protect_content": protect_content, "reply_to_message_id": reply_to_message_id, } text = self.html_text if (self.text or self.caption) else None @@ -2899,7 +3099,9 @@ class Message(base.TelegramObject): video_note=self.video_note.file_id, **kwargs ) elif self.voice: - return await self.bot.send_voice(voice=self.voice.file_id, **kwargs) + return await self.bot.send_voice( + voice=self.voice.file_id, caption=text, **kwargs + ) elif self.contact: kwargs.pop("parse_mode") return await self.bot.send_contact( @@ -2952,6 +3154,7 @@ class Message(base.TelegramObject): parse_mode: typing.Optional[base.String] = None, caption_entities: typing.Optional[typing.List[MessageEntity]] = None, disable_notification: typing.Optional[base.Boolean] = None, + protect_content: typing.Optional[base.Boolean] = None, reply_to_message_id: typing.Optional[base.Integer] = None, allow_sending_without_reply: typing.Optional[base.Boolean] = None, reply_markup: typing.Union[InlineKeyboardMarkup, @@ -2967,6 +3170,7 @@ class Message(base.TelegramObject): 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 @@ -3039,10 +3243,10 @@ class ContentType(helper.Helper): GROUP_CHAT_CREATED = helper.Item() # group_chat_created PASSPORT_DATA = helper.Item() # passport_data PROXIMITY_ALERT_TRIGGERED = helper.Item() # proximity_alert_triggered - VOICE_CHAT_SCHEDULED = helper.Item() # voice_chat_scheduled - VOICE_CHAT_STARTED = helper.Item() # voice_chat_started - VOICE_CHAT_ENDED = helper.Item() # voice_chat_ended - VOICE_CHAT_PARTICIPANTS_INVITED = helper.Item() # voice_chat_participants_invited + VOICE_CHAT_SCHEDULED = helper.Item() # voice_chat_scheduled + VOICE_CHAT_STARTED = helper.Item() # voice_chat_started + VOICE_CHAT_ENDED = helper.Item() # voice_chat_ended + VOICE_CHAT_PARTICIPANTS_INVITED = helper.Item() # voice_chat_participants_invited UNKNOWN = helper.Item() # unknown ANY = helper.Item() # any diff --git a/aiogram/types/message_entity.py b/aiogram/types/message_entity.py index 9ee98c11..9788af05 100644 --- a/aiogram/types/message_entity.py +++ b/aiogram/types/message_entity.py @@ -48,12 +48,12 @@ class MessageEntity(base.TelegramObject): :return: part of text """ if sys.maxunicode == 0xFFFF: - return text[self.offset : self.offset + self.length] + return text[self.offset: self.offset + self.length] entity_text = ( text.encode("utf-16-le") if not isinstance(text, bytes) else text ) - entity_text = entity_text[self.offset * 2 : (self.offset + self.length) * 2] + entity_text = entity_text[self.offset * 2: (self.offset + self.length) * 2] return entity_text.decode("utf-16-le") @deprecated( @@ -77,6 +77,9 @@ class MessageEntity(base.TelegramObject): if self.type == MessageEntityType.ITALIC: method = markdown.hitalic if as_html else markdown.italic return method(entity_text) + if self.type == MessageEntityType.SPOILER: + method = markdown.spoiler if as_html else markdown.hspoiler + return method(entity_text) if self.type == MessageEntityType.PRE: method = markdown.hpre if as_html else markdown.pre return method(entity_text) @@ -108,10 +111,11 @@ class MessageEntityType(helper.Helper): :key: PHONE_NUMBER :key: BOLD :key: ITALIC - :key: CODE - :key: PRE :key: UNDERLINE :key: STRIKETHROUGH + :key: SPOILER + :key: CODE + :key: PRE :key: TEXT_LINK :key: TEXT_MENTION """ @@ -127,9 +131,10 @@ class MessageEntityType(helper.Helper): PHONE_NUMBER = helper.Item() # phone_number BOLD = helper.Item() # bold - bold text ITALIC = helper.Item() # italic - italic text - CODE = helper.Item() # code - monowidth string - PRE = helper.Item() # pre - monowidth block UNDERLINE = helper.Item() # underline STRIKETHROUGH = helper.Item() # strikethrough + SPOILER = helper.Item() # spoiler + CODE = helper.Item() # code - monowidth string + PRE = helper.Item() # pre - monowidth block TEXT_LINK = helper.Item() # text_link - for clickable text URLs TEXT_MENTION = helper.Item() # text_mention - for users without usernames diff --git a/aiogram/types/mixins.py b/aiogram/types/mixins.py index 13f8412f..7d06d4c4 100644 --- a/aiogram/types/mixins.py +++ b/aiogram/types/mixins.py @@ -1,5 +1,9 @@ import os import pathlib +from io import IOBase +from typing import Union, Optional + +from aiogram.utils.deprecated import warn_deprecated class Downloadable: @@ -7,32 +11,83 @@ class Downloadable: Mixin for files """ - async def download(self, destination=None, timeout=30, chunk_size=65536, seek=True, make_dirs=True): + async def download( + self, + destination=None, + timeout=30, + chunk_size=65536, + seek=True, + make_dirs=True, + *, + destination_dir: Optional[Union[str, pathlib.Path]] = None, + destination_file: Optional[Union[str, pathlib.Path, IOBase]] = None + ): """ Download file - :param destination: filename or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` + At most one of these parameters can be used: :param destination_dir:, :param destination_file: + + :param destination: deprecated, use :param destination_dir: or :param destination_file: instead :param timeout: Integer :param chunk_size: Integer :param seek: Boolean - go to start of file when downloading is finished. :param make_dirs: Make dirs if not exist + :param destination_dir: directory for saving files + :param destination_file: path to the file or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` :return: destination """ + if destination: + warn_deprecated( + "destination parameter is deprecated, please use destination_dir or destination_file." + ) + if destination_dir and destination_file: + raise ValueError( + "Use only one of the parameters: destination_dir or destination_file." + ) + + file, destination = await self._prepare_destination( + destination, + destination_dir, + destination_file, + ) + + return await self.bot.download_file( + file_path=file.file_path, + destination=destination, + timeout=timeout, + chunk_size=chunk_size, + seek=seek, + make_dirs=make_dirs + ) + + async def _prepare_destination(self, dest, destination_dir, destination_file): file = await self.get_file() - is_path = True - if destination is None: + if not(any((dest, destination_dir, destination_file))): destination = file.file_path - elif isinstance(destination, (str, pathlib.Path)) and os.path.isdir(destination): - destination = os.path.join(destination, file.file_path) + + elif dest: # backward compatibility + if isinstance(dest, IOBase): + return file, dest + if isinstance(dest, (str, pathlib.Path)) and os.path.isdir(dest): + destination = os.path.join(dest, file.file_path) + else: + destination = dest + + elif destination_dir: + if isinstance(destination_dir, (str, pathlib.Path)): + destination = os.path.join(destination_dir, file.file_path) + else: + raise TypeError("destination_dir must be str or pathlib.Path") else: - is_path = False + if isinstance(destination_file, IOBase): + return file, destination_file + elif isinstance(destination_file, (str, pathlib.Path)): + destination = destination_file + else: + raise TypeError("destination_file must be str, pathlib.Path or io.IOBase type") - if is_path and make_dirs: - os.makedirs(os.path.dirname(destination), exist_ok=True) - - return await self.bot.download_file(file_path=file.file_path, destination=destination, timeout=timeout, - chunk_size=chunk_size, seek=seek) + return file, destination async def get_file(self): """ diff --git a/aiogram/types/reply_keyboard.py b/aiogram/types/reply_keyboard.py index 8455aff6..17b0a353 100644 --- a/aiogram/types/reply_keyboard.py +++ b/aiogram/types/reply_keyboard.py @@ -35,14 +35,17 @@ class ReplyKeyboardMarkup(base.TelegramObject): one_time_keyboard: base.Boolean = None, input_field_placeholder: base.String = None, selective: base.Boolean = None, - row_width: base.Integer = 3): + row_width: base.Integer = 3, + conf=None): + if conf is None: + conf = {} super().__init__( keyboard=keyboard, resize_keyboard=resize_keyboard, one_time_keyboard=one_time_keyboard, input_field_placeholder=input_field_placeholder, selective=selective, - conf={'row_width': row_width}, + conf={'row_width': row_width, **conf}, ) @property @@ -129,7 +132,9 @@ class KeyboardButton(base.TelegramObject): class ReplyKeyboardRemove(base.TelegramObject): """ - Upon receiving a message with this object, Telegram clients will remove the current custom keyboard and display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by a bot. An exception is made for one-time keyboards that are hidden immediately after the user presses a button (see ReplyKeyboardMarkup). + Upon receiving a message with this object, Telegram clients will remove the current custom keyboard + and display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by a bot. + An exception is made for one-time keyboards that are hidden immediately after the user presses a button (see ReplyKeyboardMarkup). https://core.telegram.org/bots/api#replykeyboardremove """ diff --git a/aiogram/types/sticker.py b/aiogram/types/sticker.py index ea222831..afaeb31c 100644 --- a/aiogram/types/sticker.py +++ b/aiogram/types/sticker.py @@ -41,8 +41,6 @@ class Sticker(base.TelegramObject, mixins.Downloadable): Source: https://core.telegram.org/bots/api#deletestickerfromset - :param sticker: File identifier of the sticker - :type sticker: :obj:`base.String` :return: Returns True on success :rtype: :obj:`base.Boolean` """ diff --git a/aiogram/types/update.py b/aiogram/types/update.py index e2fd3a55..4d5a74d5 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -10,6 +10,7 @@ from .message import Message from .poll import Poll, PollAnswer from .pre_checkout_query import PreCheckoutQuery from .shipping_query import ShippingQuery +from .chat_join_request import ChatJoinRequest from ..utils import helper, deprecated @@ -34,6 +35,7 @@ class Update(base.TelegramObject): poll_answer: PollAnswer = fields.Field(base=PollAnswer) my_chat_member: ChatMemberUpdated = fields.Field(base=ChatMemberUpdated) chat_member: ChatMemberUpdated = fields.Field(base=ChatMemberUpdated) + chat_join_request: ChatJoinRequest = fields.Field(base=ChatJoinRequest) def __hash__(self): return self.update_id @@ -66,6 +68,7 @@ class AllowedUpdates(helper.Helper): POLL_ANSWER = helper.ListItem() # poll_answer MY_CHAT_MEMBER = helper.ListItem() # my_chat_member CHAT_MEMBER = helper.ListItem() # chat_member + CHAT_JOIN_REQUEST = helper.ListItem() # chat_join_request CHOSEN_INLINE_QUERY = deprecated.DeprecatedReadOnlyClassVar( "`CHOSEN_INLINE_QUERY` is a deprecated value for allowed update. " diff --git a/aiogram/types/user.py b/aiogram/types/user.py index 8263cfc2..6bde2dcd 100644 --- a/aiogram/types/user.py +++ b/aiogram/types/user.py @@ -64,10 +64,10 @@ class User(base.TelegramObject): return getattr(self, '_locale') @property - def url(self): + def url(self) -> str: return f"tg://user?id={self.id}" - def get_mention(self, name=None, as_html=None): + def get_mention(self, name: Optional[str] = None, as_html: Optional[bool] = None) -> str: if as_html is None and self.bot.parse_mode and self.bot.parse_mode.lower() == 'html': as_html = True diff --git a/aiogram/types/voice_chat_scheduled.py b/aiogram/types/voice_chat_scheduled.py index c134eb0f..60655ebf 100644 --- a/aiogram/types/voice_chat_scheduled.py +++ b/aiogram/types/voice_chat_scheduled.py @@ -2,7 +2,6 @@ from datetime import datetime from . import base from . import fields -from .user import User class VoiceChatScheduled(base.TelegramObject): diff --git a/aiogram/utils/callback_data.py b/aiogram/utils/callback_data.py index d44fa5b9..34ba8b71 100644 --- a/aiogram/utils/callback_data.py +++ b/aiogram/utils/callback_data.py @@ -33,8 +33,6 @@ class CallbackData: raise ValueError("Prefix can't be empty") if sep in prefix: raise ValueError(f"Separator {sep!r} can't be used in prefix") - if not parts: - raise TypeError('Parts were not passed!') self.prefix = prefix self.sep = sep @@ -64,8 +62,6 @@ class CallbackData: if value is not None and not isinstance(value, str): value = str(value) - if not value: - raise ValueError(f"Value for part {part!r} can't be empty!'") if self.sep in value: raise ValueError(f"Symbol {self.sep!r} is defined as the separator and can't be used in parts' values") diff --git a/aiogram/utils/deprecated.py b/aiogram/utils/deprecated.py index 6d0d7ee3..186fe6cc 100644 --- a/aiogram/utils/deprecated.py +++ b/aiogram/utils/deprecated.py @@ -131,6 +131,57 @@ def renamed_argument(old_name: str, new_name: str, until_version: str, stackleve return decorator +def removed_argument(name: str, until_version: str, stacklevel: int = 3): + """ + A meta-decorator to mark an argument as removed. + + .. code-block:: python3 + + @removed_argument("until_date", "3.0") # stacklevel=3 by default + def some_function(user_id, chat_id=None): + print(f"user_id={user_id}, chat_id={chat_id}") + + :param name: + :param until_version: the version in which the argument is scheduled to be removed + :param stacklevel: leave it to default if it's the first decorator used. + Increment with any new decorator used. + :return: decorator + """ + + def decorator(func): + is_coroutine = asyncio.iscoroutinefunction(func) + + def _handling(kwargs): + """ + Returns updated version of kwargs. + """ + routine_type = 'coroutine' if is_coroutine else 'function' + if name in kwargs: + warn_deprecated( + f"In {routine_type} {func.__name__!r} argument {name!r} " + f"is planned to be removed in aiogram {until_version}", + stacklevel=stacklevel, + ) + kwargs = kwargs.copy() + del kwargs[name] + return kwargs + + if is_coroutine: + @functools.wraps(func) + async def wrapped(*args, **kwargs): + kwargs = _handling(kwargs) + return await func(*args, **kwargs) + else: + @functools.wraps(func) + def wrapped(*args, **kwargs): + kwargs = _handling(kwargs) + return func(*args, **kwargs) + + return wrapped + + return decorator + + _VT = TypeVar("_VT") _OwnerCls = TypeVar("_OwnerCls") diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index c74827b0..d93af29a 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -314,7 +314,7 @@ class Executor: :param timeout: """ self._prepare_polling() - loop: asyncio.AbstractEventLoop = self.loop + loop = asyncio.get_event_loop() try: loop.run_until_complete(self._startup_polling()) @@ -365,7 +365,8 @@ class Executor: self.dispatcher.stop_polling() await self.dispatcher.storage.close() await self.dispatcher.storage.wait_closed() - await self.dispatcher.bot.session.close() + session = await self.dispatcher.bot.get_session() + await session.close() async def _startup_polling(self): await self._welcome() diff --git a/aiogram/utils/helper.py b/aiogram/utils/helper.py index 55a134a3..3a9df56e 100644 --- a/aiogram/utils/helper.py +++ b/aiogram/utils/helper.py @@ -79,7 +79,7 @@ class HelperMode(Helper): @classmethod def _snake_case(cls, text): """ - Transform text to snake cale (Based on SCREAMING_SNAKE_CASE) + Transform text to snake case (Based on SCREAMING_SNAKE_CASE) :param text: :return: diff --git a/aiogram/utils/json.py b/aiogram/utils/json.py index 56f122e4..cf6087ed 100644 --- a/aiogram/utils/json.py +++ b/aiogram/utils/json.py @@ -20,28 +20,46 @@ for json_lib in (RAPIDJSON, UJSON): break if mode == RAPIDJSON: + + def dump(*args, **kwargs): + return json.dump(*args, **kwargs) + + def load(*args, **kwargs): + return json.load(*args, **kwargs) + def dumps(data): return json.dumps(data, ensure_ascii=False) - def loads(data): return json.loads(data, number_mode=json.NM_NATIVE) + elif mode == UJSON: + + def dump(*args, **kwargs): + return json.dump(*args, **kwargs) + + def load(*args, **kwargs): + return json.load(*args, **kwargs) + def loads(data): return json.loads(data) - def dumps(data): return json.dumps(data, ensure_ascii=False) + else: import json + def dump(*args, **kwargs): + return json.dump(*args, **kwargs) + + def load(*args, **kwargs): + return json.load(*args, **kwargs) def dumps(data): return json.dumps(data, ensure_ascii=False) - def loads(data): return json.loads(data) diff --git a/aiogram/utils/markdown.py b/aiogram/utils/markdown.py index da27bc39..dfce1096 100644 --- a/aiogram/utils/markdown.py +++ b/aiogram/utils/markdown.py @@ -7,10 +7,13 @@ MD_SYMBOLS = ( (LIST_MD_SYMBOLS[1], LIST_MD_SYMBOLS[1]), (LIST_MD_SYMBOLS[2], LIST_MD_SYMBOLS[2]), (LIST_MD_SYMBOLS[2] * 3 + "\n", "\n" + LIST_MD_SYMBOLS[2] * 3), + ("||", "||"), ("", ""), ("", ""), ("", ""), ("
", "
"), + ('', ""), + ("", ""), ) HTML_QUOTES_MAP = {"<": "<", ">": ">", "&": "&", '"': """} @@ -113,6 +116,32 @@ def hitalic(*content, sep=" ") -> str: ) +def spoiler(*content, sep=" ") -> str: + """ + Make spoiler text (Markdown) + + :param content: + :param sep: + :return: + """ + return markdown_decoration.spoiler( + value=markdown_decoration.quote(_join(*content, sep=sep)) + ) + + +def hspoiler(*content, sep=" ") -> str: + """ + Make spoiler text (HTML) + + :param content: + :param sep: + :return: + """ + return html_decoration.spoiler( + value=html_decoration.quote(_join(*content, sep=sep)) + ) + + def code(*content, sep=" ") -> str: """ Make mono-width text (Markdown) @@ -247,4 +276,4 @@ def hide_link(url: str) -> str: :param url: :return: """ - return f'' + return f'' diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 40fe296b..ae9af7d4 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -27,9 +27,9 @@ class TextDecoration(ABC): :return: """ if entity.type in {"bot_command", "url", "mention", "phone_number"}: - # This entities should not be changed + # These entities should not be changed return text - if entity.type in {"bold", "italic", "code", "underline", "strikethrough"}: + if entity.type in {"bold", "italic", "spoiler", "code", "underline", "strikethrough"}: return cast(str, getattr(self, entity.type)(value=text)) if entity.type == "pre": return ( @@ -115,6 +115,10 @@ class TextDecoration(ABC): def italic(self, value: str) -> str: # pragma: no cover pass + @abstractmethod + def spoiler(self, value: str) -> str: # pragma: no cover + pass + @abstractmethod def code(self, value: str) -> str: # pragma: no cover pass @@ -150,6 +154,9 @@ class HtmlDecoration(TextDecoration): def italic(self, value: str) -> str: return f"{value}" + def spoiler(self, value: str) -> str: + return f'{value}' + def code(self, value: str) -> str: return f"{value}" @@ -181,6 +188,9 @@ class MarkdownDecoration(TextDecoration): def italic(self, value: str) -> str: return f"_\r{value}_\r" + def spoiler(self, value: str) -> str: + return f"||{value}||" + def code(self, value: str) -> str: return f"`{value}`" diff --git a/docs/source/dispatcher/fsm.rst b/docs/source/dispatcher/fsm.rst index 1b00e81e..dc3a868e 100644 --- a/docs/source/dispatcher/fsm.rst +++ b/docs/source/dispatcher/fsm.rst @@ -19,7 +19,7 @@ Memory storage Redis storage ~~~~~~~~~~~~~ -.. autoclass:: aiogram.contrib.fsm_storage.redis.RedisStorage +.. autoclass:: aiogram.contrib.fsm_storage.redis.RedisStorage2 :show-inheritance: Mongo storage diff --git a/docs/source/index.rst b/docs/source/index.rst index cd4b99d0..98bd08ed 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,7 +22,7 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.6-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/examples/check_user_language.py b/examples/check_user_language.py index 98bed8a6..31c2a5f3 100644 --- a/examples/check_user_language.py +++ b/examples/check_user_language.py @@ -11,7 +11,7 @@ API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.INFO) -bot = Bot(token=API_TOKEN, parse_mode=types.ParseMode.MARKDOWN) +bot = Bot(token=API_TOKEN, parse_mode=types.ParseMode.MARKDOWN_V2) dp = Dispatcher(bot) diff --git a/examples/custom_filter_example.py b/examples/custom_filter_example.py new file mode 100644 index 00000000..beebbfcf --- /dev/null +++ b/examples/custom_filter_example.py @@ -0,0 +1,125 @@ +from typing import List, Union +from aiogram import Bot, Dispatcher, executor, types +from aiogram.dispatcher.filters import BoundFilter + +API_TOKEN = "BOT_TOKEN_HERE" + + +ADMIN_IDS = [ + 000000000, + 111111111, + 222222222, + 333333333, + 444444444, +] + + +bot = Bot(token=API_TOKEN) +dp = Dispatcher(bot) + + +class GlobalAdminFilter(BoundFilter): + """ + Check if the user is a bot admin + """ + + key = "global_admin" + + def __init__(self, global_admin: bool): + self.global_admin = global_admin + + async def check(self, obj: Union[types.Message, types.CallbackQuery]): + user = obj.from_user + if user.id in ADMIN_IDS: + return self.global_admin is True + return self.global_admin is False + + +class MimeTypeFilter(BoundFilter): + """ + Check document mime_type + """ + + key = "mime_type" + + def __init__(self, mime_type: Union[str, List[str]]): + if isinstance(mime_type, str): + self.mime_types = [mime_type] + + elif isinstance(mime_type, list): + self.mime_types = mime_type + + else: + raise ValueError( + f"filter mime_types must be a str or list of str, not {type(mime_type).__name__}" + ) + + async def check(self, obj: types.Message): + if not obj.document: + return False + + if obj.document.mime_type in self.mime_types: + return True + + return False + + +class LettersInMessageFilter(BoundFilter): + """ + Checking for the number of characters in a message/callback_data + """ + + key = "letters" + + def __init__(self, letters: int): + if isinstance(letters, int): + self.letters = letters + else: + raise ValueError( + f"filter letters must be a int, not {type(letters).__name__}" + ) + + async def check(self, obj: Union[types.Message, types.CallbackQuery]): + data = obj.text or obj.data + if data: + letters_in_message = len(data) + if letters_in_message > self.letters: + return False + return {"letters": letters_in_message} + return False + + +# Binding filters +dp.filters_factory.bind( + GlobalAdminFilter, + exclude_event_handlers=[dp.channel_post_handlers, dp.edited_channel_post_handlers], +) +dp.filters_factory.bind(MimeTypeFilter, event_handlers=[dp.message_handlers]) +dp.filters_factory.bind(LettersInMessageFilter) + + +@dp.message_handler(letters=5) +async def handle_letters_in_message(message: types.Message, letters: int): + await message.answer(f"Message too short!\nYou sent only {letters} letters") + + +@dp.message_handler(content_types=types.ContentTypes.DOCUMENT, mime_type="text/plain") +async def handle_txt_documents(message: types.Message): + await message.answer("This is a text file!") + + +@dp.message_handler( + content_types=types.ContentTypes.DOCUMENT, mime_type=["image/jpeg", "image/png"] +) +async def handle_photo_documents(message: types.Message): + await message.answer("This is a photo file!") + + +@dp.message_handler(global_admin=True) +async def handle_admins(message: types.Message): + await message.answer("Congratulations, you are global admin!") + + +if __name__ == "__main__": + allowed_updates = types.AllowedUpdates.MESSAGE | types.AllowedUpdates.CALLBACK_QUERY + executor.start_polling(dp, allowed_updates=allowed_updates, skip_updates=True) diff --git a/examples/proxy_and_emojize.py b/examples/proxy_and_emojize.py index 5ef40608..84ad74a8 100644 --- a/examples/proxy_and_emojize.py +++ b/examples/proxy_and_emojize.py @@ -50,7 +50,7 @@ async def cmd_start(message: types.Message): # This line is formatted to '🌎 *IP:* `YOUR IP`' # Make request through bot's proxy - ip = await fetch(GET_IP_URL, bot.session) + ip = await fetch(GET_IP_URL, await bot.get_session()) content.append(text(':locked_with_key:', bold('IP:'), code(ip), italic('via proxy'))) # This line is formatted to '🔐 *IP:* `YOUR IP` _via proxy_' diff --git a/requirements.txt b/requirements.txt index 6f393257..396e9526 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -aiohttp>=3.7.2,<4.0.0 -Babel>=2.8.0 -certifi>=2020.6.20 +aiohttp>=3.8.0,<3.9.0 +Babel>=2.9.1,<2.10.0 +certifi>=2021.10.8 diff --git a/setup.py b/setup.py index 6f12b437..a381507a 100755 --- a/setup.py +++ b/setup.py @@ -58,19 +58,20 @@ setup( 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Topic :: Software Development :: Libraries :: Application Frameworks', ], install_requires=[ - 'aiohttp>=3.7.2,<4.0.0', - 'Babel>=2.8.0', - 'certifi>=2020.6.20', + 'aiohttp>=3.8.0,<3.9.0', + 'Babel>=2.9.1,<2.10.0', + 'certifi>=2021.10.8', ], extras_require={ 'proxy': [ 'aiohttp-socks>=0.5.3,<0.6.0', ], 'fast': [ - 'uvloop>=0.14.0,<0.15.0', + 'uvloop>=0.16.0,<0.17.0', 'ujson>=1.35', ], }, diff --git a/tests/__init__.py b/tests/__init__.py index 920d5663..fe025192 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,7 +26,7 @@ class FakeTelegram(aresponses.ResponsesMockServer): @staticmethod def parse_data(message_data): - import json + from aiogram.utils import json from aiogram.utils.payload import _normalize _body = '{"ok":true,"result":' + json.dumps(_normalize(message_data)) + '}' diff --git a/tests/conftest.py b/tests/conftest.py index 03c8dbe4..b56c7b77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,28 +1,53 @@ +import aioredis import pytest from _pytest.config import UsageError -import aioredis.util + +try: + import aioredis.util +except ImportError: + pass def pytest_addoption(parser): - parser.addoption("--redis", default=None, - help="run tests which require redis connection") + parser.addoption( + "--redis", + default=None, + help="run tests which require redis connection", + ) def pytest_configure(config): - config.addinivalue_line("markers", "redis: marked tests require redis connection to run") + config.addinivalue_line( + "markers", + "redis: marked tests require redis connection to run", + ) def pytest_collection_modifyitems(config, items): redis_uri = config.getoption("--redis") if redis_uri is None: - skip_redis = pytest.mark.skip(reason="need --redis option with redis URI to run") + skip_redis = pytest.mark.skip( + reason="need --redis option with redis URI to run" + ) for item in items: if "redis" in item.keywords: item.add_marker(skip_redis) return + + redis_version = int(aioredis.__version__.split(".")[0]) + options = None + if redis_version == 1: + (host, port), options = aioredis.util.parse_url(redis_uri) + options.update({'host': host, 'port': port}) + elif redis_version == 2: + try: + options = aioredis.connection.parse_url(redis_uri) + except ValueError as e: + raise UsageError(f"Invalid redis URI {redis_uri!r}: {e}") + try: - address, options = aioredis.util.parse_url(redis_uri) - assert isinstance(address, tuple), "Only redis and rediss schemas are supported, eg redis://foo." + assert isinstance(options, dict), \ + "Only redis and rediss schemas are supported, eg redis://foo." except AssertionError as e: raise UsageError(f"Invalid redis URI {redis_uri!r}: {e}") @@ -30,6 +55,20 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture(scope='session') def redis_options(request): redis_uri = request.config.getoption("--redis") - (host, port), options = aioredis.util.parse_url(redis_uri) - options.update({'host': host, 'port': port}) - return options + if redis_uri is None: + pytest.skip("need --redis option with redis URI to run") + return + + redis_version = int(aioredis.__version__.split(".")[0]) + if redis_version == 1: + (host, port), options = aioredis.util.parse_url(redis_uri) + options.update({'host': host, 'port': port}) + return options + + if redis_version == 2: + try: + return aioredis.connection.parse_url(redis_uri) + except ValueError as e: + raise UsageError(f"Invalid redis URI {redis_uri!r}: {e}") + + raise UsageError("Unsupported aioredis version") diff --git a/tests/contrib/fsm_storage/test_storage.py b/tests/contrib/fsm_storage/test_storage.py index 0cde2de2..2668cdab 100644 --- a/tests/contrib/fsm_storage/test_storage.py +++ b/tests/contrib/fsm_storage/test_storage.py @@ -1,12 +1,16 @@ +import aioredis import pytest - +from pytest_lazyfixture import lazy_fixture from aiogram.contrib.fsm_storage.memory import MemoryStorage -from aiogram.contrib.fsm_storage.redis import RedisStorage2, RedisStorage +from aiogram.contrib.fsm_storage.redis import RedisStorage, RedisStorage2 @pytest.fixture() @pytest.mark.redis async def redis_store(redis_options): + if int(aioredis.__version__.split(".")[0]) == 2: + pytest.skip('aioredis v2 is not supported.') + return s = RedisStorage(**redis_options) try: yield s @@ -37,9 +41,9 @@ async def memory_store(): @pytest.mark.parametrize( "store", [ - pytest.lazy_fixture('redis_store'), - pytest.lazy_fixture('redis_store2'), - pytest.lazy_fixture('memory_store'), + lazy_fixture('redis_store'), + lazy_fixture('redis_store2'), + lazy_fixture('memory_store'), ] ) class TestStorage: @@ -63,8 +67,8 @@ class TestStorage: @pytest.mark.parametrize( "store", [ - pytest.lazy_fixture('redis_store'), - pytest.lazy_fixture('redis_store2'), + lazy_fixture('redis_store'), + lazy_fixture('redis_store2'), ] ) class TestRedisStorage2: @@ -74,6 +78,7 @@ class TestRedisStorage2: assert await store.get_data(chat='1234') == {'foo': 'bar'} pool_id = id(store._redis) await store.close() + await store.wait_closed() assert await store.get_data(chat='1234') == { 'foo': 'bar'} # new pool was opened at this point assert id(store._redis) != pool_id diff --git a/tests/test_bot.py b/tests/test_bot.py index 61abe962..b2da7952 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -425,14 +425,19 @@ async def test_get_chat(bot: Bot): async def test_get_chat_administrators(bot: Bot): """ getChatAdministrators method test """ - from .types.dataset import CHAT, CHAT_MEMBER + from .types.dataset import CHAT, CHAT_MEMBER, CHAT_MEMBER_OWNER chat = types.Chat(**CHAT) member = types.ChatMember.resolve(**CHAT_MEMBER) + owner = types.ChatMember.resolve(**CHAT_MEMBER_OWNER) - async with FakeTelegram(message_data=[CHAT_MEMBER, CHAT_MEMBER]): + async with FakeTelegram(message_data=[CHAT_MEMBER, CHAT_MEMBER_OWNER]): result = await bot.get_chat_administrators(chat_id=chat.id) assert result[0] == member + assert result[1] == owner assert len(result) == 2 + for m in result: + assert m.is_chat_admin() + assert hasattr(m, "can_be_edited") async def test_get_chat_member_count(bot: Bot): diff --git a/tests/test_bot/test_bot_download_file.py b/tests/test_bot/test_bot_download_file.py new file mode 100644 index 00000000..195a06f7 --- /dev/null +++ b/tests/test_bot/test_bot_download_file.py @@ -0,0 +1,79 @@ +import os +from io import BytesIO +from pathlib import Path + +import pytest + +from aiogram import Bot +from aiogram.types import File +from tests import TOKEN +from tests.types.dataset import FILE + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(name='bot') +async def bot_fixture(): + async def get_file(): + return File(**FILE) + + """ Bot fixture """ + _bot = Bot(TOKEN) + _bot.get_file = get_file + yield _bot + session = await _bot.get_session() + await session.close() + + +@pytest.fixture +def file(): + return File(**FILE) + + +@pytest.fixture +def tmppath(tmpdir, request): + os.chdir(tmpdir) + yield Path(tmpdir) + os.chdir(request.config.invocation_dir) + + +class TestBotDownload: + async def test_download_file(self, tmppath, bot, file): + f = await bot.download_file(file_path=file.file_path) + assert len(f.read()) != 0 + + async def test_download_file_destination(self, tmppath, bot, file): + await bot.download_file(file_path=file.file_path, destination="test.file") + assert os.path.isfile(tmppath.joinpath('test.file')) + + async def test_download_file_destination_with_dir(self, tmppath, bot, file): + await bot.download_file(file_path=file.file_path, + destination=os.path.join('dir_name', 'file_name')) + assert os.path.isfile(tmppath.joinpath('dir_name', 'file_name')) + + async def test_download_file_destination_raise_file_not_found(self, tmppath, bot, file): + with pytest.raises(FileNotFoundError): + await bot.download_file(file_path=file.file_path, + destination=os.path.join('dir_name', 'file_name'), + make_dirs=False) + + async def test_download_file_destination_io_bytes(self, tmppath, bot, file): + f = BytesIO() + await bot.download_file(file_path=file.file_path, + destination=f) + assert len(f.read()) != 0 + + async def test_download_file_raise_value_error(self, tmppath, bot, file): + with pytest.raises(ValueError): + await bot.download_file(file_path=file.file_path, destination="a", destination_dir="b") + + async def test_download_file_destination_dir(self, tmppath, bot, file): + await bot.download_file(file_path=file.file_path, destination_dir='test_dir') + assert os.path.isfile(tmppath.joinpath('test_dir', file.file_path)) + + async def test_download_file_destination_dir_raise_file_not_found(self, tmppath, bot, file): + with pytest.raises(FileNotFoundError): + await bot.download_file(file_path=file.file_path, + destination_dir='test_dir', + make_dirs=False) + assert os.path.isfile(tmppath.joinpath('test_dir', file.file_path)) diff --git a/tests/test_bot/test_session.py b/tests/test_bot/test_session.py index dec6379c..1f8417e7 100644 --- a/tests/test_bot/test_session.py +++ b/tests/test_bot/test_session.py @@ -23,7 +23,6 @@ class TestAiohttpSession: assert bot._session is None - assert isinstance(bot.session, aiohttp.ClientSession) assert bot.session == bot._session @pytest.mark.asyncio @@ -51,11 +50,11 @@ class TestAiohttpSession: @pytest.mark.asyncio async def test_close_session(self): bot = BaseBot(token="42:correct",) - aiohttp_client_0 = bot.session + aiohttp_client_0 = await bot.get_session() with patch("aiohttp.ClientSession.close", new=CoroutineMock()) as mocked_close: await aiohttp_client_0.close() mocked_close.assert_called_once() await aiohttp_client_0.close() - assert aiohttp_client_0 != bot.session # will create new session + assert aiohttp_client_0 != await bot.get_session() # will create new session diff --git a/tests/test_dispatcher/test_fsm_context.py b/tests/test_dispatcher/test_fsm_context.py new file mode 100644 index 00000000..3189e4e6 --- /dev/null +++ b/tests/test_dispatcher/test_fsm_context.py @@ -0,0 +1,14 @@ +import pytest +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.dispatcher import FSMContext + + +class TestFSMContext: + @pytest.mark.asyncio + async def test_update_data(self): + context = FSMContext(MemoryStorage(), chat=1, user=1) + async with context.proxy() as data: + data.update(key1="value1", key2="value2") + async with context.proxy() as data: + assert data['key1'] == "value1" + assert data['key2'] == "value2" diff --git a/tests/test_utils/test_callback_data.py b/tests/test_utils/test_callback_data.py new file mode 100644 index 00000000..c337c527 --- /dev/null +++ b/tests/test_utils/test_callback_data.py @@ -0,0 +1,39 @@ +import pytest + +from aiogram.types import CallbackQuery +from aiogram.utils.callback_data import CallbackData + + +class TestCallbackData: + @pytest.mark.asyncio + async def test_cb(self): + cb = CallbackData('simple', 'action') + assert cb.new('x') == 'simple:x' + assert cb.new(action='y') == 'simple:y' + assert cb.new('') == 'simple:' + + assert (await cb.filter().check(CallbackQuery(data='simple:'))) == {'callback_data': {'@': 'simple', 'action': ''}} + assert (await cb.filter().check(CallbackQuery(data='simple:x'))) == {'callback_data': {'@': 'simple', 'action': 'x'}} + assert (await cb.filter(action='y').check(CallbackQuery(data='simple:x'))) is False + + @pytest.mark.asyncio + async def test_cb_double(self): + cb = CallbackData('double', 'pid', 'action') + assert cb.new('123', 'x') == 'double:123:x' + assert cb.new(pid=456, action='y') == 'double:456:y' + assert cb.new('', 'z') == 'double::z' + assert cb.new('789', '') == 'double:789:' + + assert (await cb.filter().check(CallbackQuery(data='double::'))) == {'callback_data': {'@': 'double', 'pid': '', 'action': ''}} + assert (await cb.filter().check(CallbackQuery(data='double:x:'))) == {'callback_data': {'@': 'double', 'pid': 'x', 'action': ''}} + assert (await cb.filter().check(CallbackQuery(data='double::y'))) == {'callback_data': {'@': 'double', 'pid': '', 'action': 'y'}} + assert (await cb.filter(action='x').check(CallbackQuery(data='double:123:x'))) == {'callback_data': {'@': 'double', 'pid': '123', 'action': 'x'}} + + @pytest.mark.asyncio + async def test_cb_zero(self): + cb = CallbackData('zero') + assert cb.new() == 'zero' + + assert (await cb.filter().check(CallbackQuery(data='zero'))) == {'callback_data': {'@': 'zero'}} + assert (await cb.filter().check(CallbackQuery(data='zero:'))) is False + assert (await cb.filter().check(CallbackQuery(data='bla'))) is False diff --git a/tests/types/dataset.py b/tests/types/dataset.py index a14ce316..be23da9e 100644 --- a/tests/types/dataset.py +++ b/tests/types/dataset.py @@ -44,12 +44,21 @@ CHAT_MEMBER = { "user": USER, "status": "administrator", "can_be_edited": False, + "can_manage_chat": True, "can_change_info": True, "can_delete_messages": True, "can_invite_users": True, "can_restrict_members": True, "can_pin_messages": True, "can_promote_members": False, + "can_manage_voice_chats": True, + "is_anonymous": False, +} + +CHAT_MEMBER_OWNER = { + "user": USER, + "status": "creator", + "is_anonymous": False, } CONTACT = { diff --git a/tests/types/test_mixins.py b/tests/types/test_mixins.py new file mode 100644 index 00000000..4ee4381a --- /dev/null +++ b/tests/types/test_mixins.py @@ -0,0 +1,102 @@ +import os +from io import BytesIO +from pathlib import Path + +import pytest + +from aiogram import Bot +from aiogram.types import File +from aiogram.types.mixins import Downloadable +from tests import TOKEN +from tests.types.dataset import FILE + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(name='bot') +async def bot_fixture(): + """ Bot fixture """ + _bot = Bot(TOKEN) + yield _bot + await (await _bot.get_session()).close() + + +@pytest.fixture +def tmppath(tmpdir, request): + os.chdir(tmpdir) + yield Path(tmpdir) + os.chdir(request.config.invocation_dir) + + +@pytest.fixture +def downloadable(bot): + async def get_file(): + return File(**FILE) + + downloadable = Downloadable() + downloadable.get_file = get_file + downloadable.bot = bot + + return downloadable + + +class TestDownloadable: + async def test_download_make_dirs_false_nodir(self, tmppath, downloadable): + with pytest.raises(FileNotFoundError): + await downloadable.download(make_dirs=False) + + async def test_download_make_dirs_false_mkdir(self, tmppath, downloadable): + os.mkdir('voice') + await downloadable.download(make_dirs=False) + assert os.path.isfile(tmppath.joinpath(FILE["file_path"])) + + async def test_download_make_dirs_true(self, tmppath, downloadable): + await downloadable.download(make_dirs=True) + assert os.path.isfile(tmppath.joinpath(FILE["file_path"])) + + async def test_download_deprecation_warning(self, tmppath, downloadable): + with pytest.deprecated_call(): + await downloadable.download("test.file") + + async def test_download_destination(self, tmppath, downloadable): + with pytest.deprecated_call(): + await downloadable.download("test.file") + assert os.path.isfile(tmppath.joinpath('test.file')) + + async def test_download_destination_dir_exist(self, tmppath, downloadable): + os.mkdir("test_folder") + with pytest.deprecated_call(): + await downloadable.download("test_folder") + assert os.path.isfile(tmppath.joinpath('test_folder', FILE["file_path"])) + + async def test_download_destination_with_dir(self, tmppath, downloadable): + with pytest.deprecated_call(): + await downloadable.download(os.path.join('dir_name', 'file_name')) + assert os.path.isfile(tmppath.joinpath('dir_name', 'file_name')) + + async def test_download_destination_io_bytes(self, tmppath, downloadable): + file = BytesIO() + with pytest.deprecated_call(): + await downloadable.download(file) + assert len(file.read()) != 0 + + async def test_download_raise_value_error(self, tmppath, downloadable): + with pytest.raises(ValueError): + await downloadable.download(destination_dir="a", destination_file="b") + + async def test_download_destination_dir(self, tmppath, downloadable): + await downloadable.download(destination_dir='test_dir') + assert os.path.isfile(tmppath.joinpath('test_dir', FILE["file_path"])) + + async def test_download_destination_file(self, tmppath, downloadable): + await downloadable.download(destination_file='file_name') + assert os.path.isfile(tmppath.joinpath('file_name')) + + async def test_download_destination_file_with_dir(self, tmppath, downloadable): + await downloadable.download(destination_file=os.path.join('dir_name', 'file_name')) + assert os.path.isfile(tmppath.joinpath('dir_name', 'file_name')) + + async def test_download_io_bytes(self, tmppath, downloadable): + file = BytesIO() + await downloadable.download(destination_file=file) + assert len(file.read()) != 0