From 69e4ecc6061e22b121cbcd580268045aa39c0ffc Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 12 Apr 2021 00:32:40 +0300 Subject: [PATCH 01/24] #564: Added possibility to use `allowed_updates` argument in Polling mode --- aiogram/__init__.py | 2 +- aiogram/dispatcher/dispatcher.py | 14 +++++++++++--- aiogram/types/update.py | 8 ++++++++ aiogram/utils/executor.py | 21 ++++++++++++++++----- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 590afc50..455aebe8 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.12.1' +__version__ = '2.12.2' __api_version__ = '5.1' diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index d471fe86..8231c4f7 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -340,7 +340,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin): limit=None, reset_webhook=None, fast: typing.Optional[bool] = True, - error_sleep: int = 5): + error_sleep: int = 5, + allowed_updates: typing.Optional[typing.List[str]] = None): """ Start long-polling @@ -349,6 +350,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin): :param limit: :param reset_webhook: :param fast: + :param error_sleep: + :param allowed_updates: :return: """ if self._polling: @@ -377,10 +380,15 @@ class Dispatcher(DataMixin, ContextInstanceMixin): while self._polling: try: with self.bot.request_timeout(request_timeout): - updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) + updates = await self.bot.get_updates( + limit=limit, + offset=offset, + timeout=timeout, + allowed_updates=allowed_updates + ) except asyncio.CancelledError: break - except: + except Exception as e: log.exception('Cause exception while getting updates.') await asyncio.sleep(error_sleep) continue diff --git a/aiogram/types/update.py b/aiogram/types/update.py index c8c4b58d..7cf616bb 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -1,5 +1,7 @@ from __future__ import annotations +from functools import lru_cache + from . import base from . import fields from .callback_query import CallbackQuery @@ -72,3 +74,9 @@ class AllowedUpdates(helper.Helper): "Use `CHOSEN_INLINE_RESULT`", new_value_getter=lambda cls: cls.CHOSEN_INLINE_RESULT, ) + + @classmethod + @lru_cache(1) + def default(cls): + excluded = cls.CHAT_MEMBER + cls.MY_CHAT_MEMBER + return list(filter(lambda item: item not in excluded, cls.all())) diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index 35107975..c74827b0 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -2,7 +2,7 @@ import asyncio import datetime import functools import secrets -from typing import Callable, Union, Optional, Any +from typing import Callable, Union, Optional, Any, List from warnings import warn from aiohttp import web @@ -23,7 +23,8 @@ def _setup_callbacks(executor: 'Executor', on_startup=None, on_shutdown=None): def start_polling(dispatcher, *, loop=None, skip_updates=False, reset_webhook=True, - on_startup=None, on_shutdown=None, timeout=20, relax=0.1, fast=True): + on_startup=None, on_shutdown=None, timeout=20, relax=0.1, fast=True, + allowed_updates: Optional[List[str]] = None): """ Start bot in long-polling mode @@ -34,11 +35,20 @@ def start_polling(dispatcher, *, loop=None, skip_updates=False, reset_webhook=Tr :param on_startup: :param on_shutdown: :param timeout: + :param relax: + :param fast: + :param allowed_updates: """ executor = Executor(dispatcher, skip_updates=skip_updates, loop=loop) _setup_callbacks(executor, on_startup, on_shutdown) - executor.start_polling(reset_webhook=reset_webhook, timeout=timeout, relax=relax, fast=fast) + executor.start_polling( + reset_webhook=reset_webhook, + timeout=timeout, + relax=relax, + fast=fast, + allowed_updates=allowed_updates + ) def set_webhook(dispatcher: Dispatcher, webhook_path: str, *, loop: Optional[asyncio.AbstractEventLoop] = None, @@ -295,7 +305,8 @@ class Executor: self.set_webhook(webhook_path=webhook_path, request_handler=request_handler, route_name=route_name) self.run_app(**kwargs) - def start_polling(self, reset_webhook=None, timeout=20, relax=0.1, fast=True): + def start_polling(self, reset_webhook=None, timeout=20, relax=0.1, fast=True, + allowed_updates: Optional[List[str]] = None): """ Start bot in long-polling mode @@ -308,7 +319,7 @@ class Executor: try: loop.run_until_complete(self._startup_polling()) loop.create_task(self.dispatcher.start_polling(reset_webhook=reset_webhook, timeout=timeout, - relax=relax, fast=fast)) + relax=relax, fast=fast, allowed_updates=allowed_updates)) loop.run_forever() except (KeyboardInterrupt, SystemExit): # loop.stop() From df294e579f104e2ae7e9f37b0c69490782d33091 Mon Sep 17 00:00:00 2001 From: Dmitriy Date: Mon, 12 Apr 2021 10:24:13 +0300 Subject: [PATCH 02/24] Replace deprecated 'is_private' method (#553) --- aiogram/types/message.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 7f083119..d0aed602 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -267,7 +267,8 @@ class Message(base.TelegramObject): :return: str """ - if ChatType.is_private(self.chat): + + if self.chat.type == ChatType.PRIVATE: raise TypeError("Invalid chat type!") url = "https://t.me/" if self.chat.username: From cce29ba532b4956557dead60edf14e5d0e5340e0 Mon Sep 17 00:00:00 2001 From: nthacks Date: Mon, 19 Apr 2021 03:59:07 +0530 Subject: [PATCH 03/24] Update documented caption limits to the current limit. (#565) --- aiogram/dispatcher/webhook.py | 10 +++++----- aiogram/types/message.py | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 52191870..bc21e22c 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -619,7 +619,7 @@ class SendPhoto(BaseResponse, ReplyToMixin, DisableNotificationMixin): a photo that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get a photo from the Internet, or upload a new photo using multipart/form-data. :param caption: String (Optional) - Photo caption (may also be used when resending photos by file_id), - 0-200 characters + 0-1024 characters after entities parsing :param disable_notification: Boolean (Optional) - Sends the message silently. Users will receive a notification with no sound. :param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message @@ -672,7 +672,7 @@ class SendAudio(BaseResponse, ReplyToMixin, DisableNotificationMixin): to send an audio file that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get an audio file from the Internet, or upload a new one using multipart/form-data. - :param caption: String (Optional) - Audio caption, 0-200 characters + :param caption: String (Optional) - Audio caption, 0-1024 characters after entities parsing :param duration: Integer (Optional) - Duration of the audio in seconds :param performer: String (Optional) - Performer :param title: String (Optional) - Track name @@ -731,7 +731,7 @@ class SendDocument(BaseResponse, ReplyToMixin, DisableNotificationMixin): as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. :param caption: String (Optional) - Document caption - (may also be used when resending documents by file_id), 0-200 characters + (may also be used when resending documents by file_id), 0-1024 characters after entities parsing :param disable_notification: Boolean (Optional) - Sends the message silently. Users will receive a notification with no sound. :param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message @@ -788,7 +788,7 @@ class SendVideo(BaseResponse, ReplyToMixin, DisableNotificationMixin): :param width: Integer (Optional) - Video width :param height: Integer (Optional) - Video height :param caption: String (Optional) - Video caption (may also be used when resending videos by file_id), - 0-200 characters + 0-1024 characters after entities parsing :param disable_notification: Boolean (Optional) - Sends the message silently. Users will receive a notification with no sound. :param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message @@ -845,7 +845,7 @@ class SendVoice(BaseResponse, ReplyToMixin, DisableNotificationMixin): to send a file that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. - :param caption: String (Optional) - Voice message caption, 0-200 characters + :param caption: String (Optional) - Voice message caption, 0-1024 characters after entities parsing :param duration: Integer (Optional) - Duration of the voice message in seconds :param disable_notification: Boolean (Optional) - Sends the message silently. Users will receive a notification with no sound. diff --git a/aiogram/types/message.py b/aiogram/types/message.py index d0aed602..c9cc3945 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -462,7 +462,7 @@ class Message(base.TelegramObject): :param audio: Audio file to send. :type audio: :obj:`typing.Union[base.InputFile, base.String]` - :param caption: Audio caption, 0-200 characters + :param caption: Audio caption, 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -739,7 +739,7 @@ class Message(base.TelegramObject): A thumbnail‘s width and height should not exceed 320. :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` - :param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters + :param caption: Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -818,7 +818,7 @@ class Message(base.TelegramObject): :param voice: Audio file to send. :type voice: :obj:`typing.Union[base.InputFile, base.String]` - :param caption: Voice message caption, 0-200 characters + :param caption: Voice message caption, 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -1603,7 +1603,7 @@ class Message(base.TelegramObject): :param audio: Audio file to send. :type audio: :obj:`typing.Union[base.InputFile, base.String]` - :param caption: Audio caption, 0-200 characters + :param caption: Audio caption, 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -1880,7 +1880,7 @@ class Message(base.TelegramObject): A thumbnail‘s width and height should not exceed 320. :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` - :param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters + :param caption: Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -1959,7 +1959,7 @@ class Message(base.TelegramObject): :param voice: Audio file to send. :type voice: :obj:`typing.Union[base.InputFile, base.String]` - :param caption: Voice message caption, 0-200 characters + :param caption: Voice message caption, 0-1024 characters after entities parsing :type caption: :obj:`typing.Optional[base.String]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, From ea28e2a77a80658341bad52cd37d2750b551fd82 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Wed, 28 Apr 2021 01:22:57 +0300 Subject: [PATCH 04/24] Telegram API 5.2 support (#572) * feat: version number update * feat: add InputInvoiceMessageContent type * refactor: every param on a new line * feat: add `max_tip_amount` and `suggested_tip_amounts` to `sendInvoice` * feat: `start_parameter` of `sendInvoice` became optional * refactor: reorder params * feat: add `chat_type` to `InlineQuery` * feat: add `VoiceChatScheduled` * feat: add `voice_chat_scheduled` to `Message` * fix: sendChatAction documentation update * feat: add `record_voice` and `upload_voice` to `ChatActions` * feat: allow sending invoices to group, supergroup and channel --- README.md | 2 +- README.rst | 2 +- aiogram/__init__.py | 4 +- aiogram/bot/api.py | 2 +- aiogram/bot/bot.py | 87 ++++++++++++++++++++------ aiogram/types/__init__.py | 5 +- aiogram/types/chat.py | 22 +++++++ aiogram/types/inline_query.py | 3 +- aiogram/types/input_message_content.py | 62 ++++++++++++++++++ aiogram/types/message.py | 5 ++ aiogram/types/voice_chat_scheduled.py | 15 +++++ docs/source/index.rst | 2 +- 12 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 aiogram/types/voice_chat_scheduled.py diff --git a/README.md b/README.md index 5205646d..ae44c524 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.1-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.2-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 3ec899c2..caf6149c 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.1-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-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 455aebe8..a77ecdc0 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.12.2' -__api_version__ = '5.1' +__version__ = '2.13.0' +__api_version__ = '5.2' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index e3d3bf9a..38cbee89 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.1 + List is updated to Bot API 5.2 """ mode = HelperMode.lowerCamelCase diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 4b6c4c0b..0c72c050 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1484,19 +1484,36 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): async def send_chat_action(self, chat_id: typing.Union[base.Integer, base.String], action: base.String) -> base.Boolean: """ - Use this method when you need to tell the user that something is happening on the bot's side. - The status is set for 5 seconds or less - (when a message arrives from your bot, Telegram clients clear its typing status). + Use this method when you need to tell the user that something is + happening on the bot's side. The status is set for 5 seconds or + less (when a message arrives from your bot, Telegram clients + clear its typing status). Returns True on success. - We only recommend using this method when a response from the bot will take - a noticeable amount of time to arrive. + Example: The ImageBot needs some time to process a request and + upload the image. Instead of sending a text message along the + lines of “Retrieving image, please wait…”, the bot may use + sendChatAction with action = upload_photo. The user will see a + “sending photo” status for the bot. + + We only recommend using this method when a response from the bot + will take a noticeable amount of time to arrive. Source: https://core.telegram.org/bots/api#sendchataction - :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 (in the format + @channelusername) :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param action: Type of action to broadcast + + :param action: Type of action to broadcast. Choose one, + depending on what the user is about to receive: `typing` for + text messages, `upload_photo` for photos, `record_video` or + `upload_video` for videos, `record_voice` or `upload_voice` + for voice notes, `upload_document` for general files, + `find_location` for location data, `record_video_note` or + `upload_video_note` for video notes. :type action: :obj:`base.String` + :return: Returns True on success :rtype: :obj:`base.Boolean` """ @@ -2780,10 +2797,19 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): # === Payments === # https://core.telegram.org/bots/api#payments - async def send_invoice(self, chat_id: base.Integer, title: base.String, - description: base.String, payload: base.String, - provider_token: base.String, start_parameter: base.String, - currency: base.String, prices: typing.List[types.LabeledPrice], + async def send_invoice(self, + chat_id: typing.Union[base.Integer, base.String], + title: base.String, + description: base.String, + payload: base.String, + provider_token: base.String, + currency: base.String, + prices: typing.List[types.LabeledPrice], + max_tip_amount: typing.Optional[base.Integer] = None, + suggested_tip_amounts: typing.Optional[ + typing.List[base.Integer] + ] = None, + start_parameter: typing.Optional[base.String] = None, provider_data: typing.Optional[typing.Dict] = None, photo_url: typing.Optional[base.String] = None, photo_size: typing.Optional[base.Integer] = None, @@ -2799,14 +2825,17 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): disable_notification: 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) -> types.Message: + reply_markup: typing.Optional[types.InlineKeyboardMarkup] = None, + ) -> types.Message: """ Use this method to send invoices. Source: https://core.telegram.org/bots/api#sendinvoice - :param chat_id: Unique identifier for the target private chat - :type chat_id: :obj:`base.Integer` + :param chat_id: Unique identifier for the target chat or + username of the target channel (in the format + @channelusername) + :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param title: Product name, 1-32 characters :type title: :obj:`base.String` @@ -2821,10 +2850,6 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :param provider_token: Payments provider token, obtained via Botfather :type provider_token: :obj:`base.String` - :param start_parameter: Unique deep-linking parameter that can be used to generate this - invoice when used as a start parameter - :type start_parameter: :obj:`base.String` - :param currency: Three-letter ISO 4217 currency code, see more on currencies :type currency: :obj:`base.String` @@ -2832,6 +2857,32 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.) :type prices: :obj:`typing.List[types.LabeledPrice]` + :param max_tip_amount: The maximum accepted amount for tips in + the smallest units of the currency (integer, not + float/double). For example, for a maximum tip of US$ 1.45 + pass max_tip_amount = 145. See the exp parameter in + currencies.json, it shows the number of digits past the + decimal point for each currency (2 for the majority of + currencies). Defaults to 0 + :type max_tip_amount: :obj:`typing.Optional[base.Integer]` + + :param suggested_tip_amounts: A JSON-serialized array of suggested + amounts of tips in the smallest units of the currency + (integer, not float/double). At most 4 suggested tip amounts + can be specified. The suggested tip amounts must be + positive, passed in a strictly increased order and must not + exceed max_tip_amount. + :type suggested_tip_amounts: :obj:`typing.Optional[typing.List[base.Integer]]` + + :param start_parameter: Unique deep-linking parameter. If left + empty, forwarded copies of the sent message will have a Pay + button, allowing multiple users to pay directly from the + forwarded message, using the same invoice. If non-empty, + forwarded copies of the sent message will have a URL button + with a deep link to the bot (instead of a Pay button), with + the value used as the start parameter + :type start_parameter: :obj:`typing.Optional[base.String]` + :param provider_data: JSON-encoded data about the invoice, which will be shared with the payment provider :type provider_data: :obj:`typing.Optional[typing.Dict]` diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 1dfa519f..90909e81 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -35,7 +35,7 @@ from .input_file import InputFile from .input_media import InputMedia, InputMediaAnimation, InputMediaAudio, InputMediaDocument, InputMediaPhoto, \ InputMediaVideo, MediaGroup from .input_message_content import InputContactMessageContent, InputLocationMessageContent, InputMessageContent, \ - InputTextMessageContent, InputVenueMessageContent + InputTextMessageContent, InputVenueMessageContent, InputInvoiceMessageContent from .invoice import Invoice from .labeled_price import LabeledPrice from .location import Location @@ -72,6 +72,7 @@ from .video_note import VideoNote from .voice import Voice from .voice_chat_ended import VoiceChatEnded from .voice_chat_participants_invited import VoiceChatParticipantsInvited +from .voice_chat_scheduled import VoiceChatScheduled from .voice_chat_started import VoiceChatStarted from .webhook_info import WebhookInfo @@ -131,6 +132,7 @@ __all__ = ( 'InlineQueryResultVideo', 'InlineQueryResultVoice', 'InputContactMessageContent', + 'InputInvoiceMessageContent', 'InputFile', 'InputLocationMessageContent', 'InputMedia', @@ -191,6 +193,7 @@ __all__ = ( 'Voice', 'VoiceChatEnded', 'VoiceChatParticipantsInvited', + 'VoiceChatScheduled', 'VoiceChatStarted', 'WebhookInfo', 'base', diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 3b0a7b9d..5b3b315a 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -732,6 +732,8 @@ class ChatActions(helper.Helper): UPLOAD_VIDEO: str = helper.Item() # upload_video RECORD_AUDIO: str = helper.Item() # record_audio UPLOAD_AUDIO: str = helper.Item() # upload_audio + RECORD_VOICE: str = helper.Item() # record_voice + UPLOAD_VOICE: str = helper.Item() # upload_voice UPLOAD_DOCUMENT: str = helper.Item() # upload_document FIND_LOCATION: str = helper.Item() # find_location RECORD_VIDEO_NOTE: str = helper.Item() # record_video_note @@ -817,6 +819,26 @@ class ChatActions(helper.Helper): """ await cls._do(cls.UPLOAD_AUDIO, sleep) + @classmethod + async def record_voice(cls, sleep=None): + """ + Do record voice + + :param sleep: sleep timeout + :return: + """ + await cls._do(cls.RECORD_VOICE, sleep) + + @classmethod + async def upload_voice(cls, sleep=None): + """ + Do upload voice + + :param sleep: sleep timeout + :return: + """ + await cls._do(cls.UPLOAD_VOICE, sleep) + @classmethod async def upload_document(cls, sleep=None): """ diff --git a/aiogram/types/inline_query.py b/aiogram/types/inline_query.py index 436c11b0..63f4ab32 100644 --- a/aiogram/types/inline_query.py +++ b/aiogram/types/inline_query.py @@ -17,9 +17,10 @@ class InlineQuery(base.TelegramObject): """ id: base.String = fields.Field() from_user: User = fields.Field(alias='from', base=User) - location: Location = fields.Field(base=Location) query: base.String = fields.Field() offset: base.String = fields.Field() + chat_type: base.String = fields.Field() + location: Location = fields.Field(base=Location) async def answer(self, results: typing.List[InlineQueryResult], diff --git a/aiogram/types/input_message_content.py b/aiogram/types/input_message_content.py index 0008a2ee..f0c452cd 100644 --- a/aiogram/types/input_message_content.py +++ b/aiogram/types/input_message_content.py @@ -3,6 +3,8 @@ import typing from . import base from . import fields from .message_entity import MessageEntity +from .labeled_price import LabeledPrice +from ..utils.payload import generate_payload class InputMessageContent(base.TelegramObject): @@ -44,6 +46,66 @@ class InputContactMessageContent(InputMessageContent): ) +class InputInvoiceMessageContent(InputMessageContent): + """ + Represents the content of an invoice message to be sent as the + result of an inline query. + + https://core.telegram.org/bots/api#inputinvoicemessagecontent + """ + + title: base.String = fields.Field() + description: base.String = fields.Field() + payload: base.String = fields.Field() + provider_token: base.String = fields.Field() + currency: base.String = fields.Field() + prices: typing.List[LabeledPrice] = fields.ListField(base=LabeledPrice) + max_tip_amount: typing.Optional[base.Integer] = fields.Field() + suggested_tip_amounts: typing.Optional[ + typing.List[base.Integer] + ] = fields.ListField(base=base.Integer) + provider_data: typing.Optional[base.String] = fields.Field() + photo_url: typing.Optional[base.String] = fields.Field() + photo_size: typing.Optional[base.Integer] = fields.Field() + photo_width: typing.Optional[base.Integer] = fields.Field() + photo_height: typing.Optional[base.Integer] = fields.Field() + need_name: typing.Optional[base.Boolean] = fields.Field() + need_phone_number: typing.Optional[base.Boolean] = fields.Field() + need_email: typing.Optional[base.Boolean] = fields.Field() + need_shipping_address: typing.Optional[base.Boolean] = fields.Field() + send_phone_number_to_provider: typing.Optional[base.Boolean] = fields.Field() + send_email_to_provider: typing.Optional[base.Boolean] = fields.Field() + is_flexible: typing.Optional[base.Boolean] = fields.Field() + + def __init__( + self, + title: base.String, + description: base.String, + payload: base.String, + provider_token: base.String, + currency: base.String, + prices: typing.List[LabeledPrice] = None, + max_tip_amount: typing.Optional[base.Integer] = None, + suggested_tip_amounts: typing.Optional[typing.List[base.Integer]] = None, + provider_data: typing.Optional[base.String] = None, + photo_url: typing.Optional[base.String] = None, + photo_size: typing.Optional[base.Integer] = None, + photo_width: typing.Optional[base.Integer] = None, + photo_height: typing.Optional[base.Integer] = None, + need_name: typing.Optional[base.Boolean] = None, + need_phone_number: typing.Optional[base.Boolean] = None, + need_email: typing.Optional[base.Boolean] = None, + need_shipping_address: typing.Optional[base.Boolean] = None, + send_phone_number_to_provider: typing.Optional[base.Boolean] = None, + send_email_to_provider: typing.Optional[base.Boolean] = None, + is_flexible: typing.Optional[base.Boolean] = None, + ): + if prices is None: + prices = [] + payload = generate_payload(**locals()) + super().__init__(**payload) + + class InputLocationMessageContent(InputMessageContent): """ Represents the content of a location message to be sent as the result of an inline query. diff --git a/aiogram/types/message.py b/aiogram/types/message.py index c9cc3945..ce8395d2 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -34,6 +34,7 @@ from .video_note import VideoNote from .voice import Voice from .voice_chat_ended import VoiceChatEnded from .voice_chat_participants_invited import VoiceChatParticipantsInvited +from .voice_chat_scheduled import VoiceChatScheduled from .voice_chat_started import VoiceChatStarted from ..utils import helper from ..utils import markdown as md @@ -98,6 +99,7 @@ class Message(base.TelegramObject): connected_website: base.String = fields.Field() passport_data: PassportData = fields.Field(base=PassportData) proximity_alert_triggered: ProximityAlertTriggered = fields.Field(base=ProximityAlertTriggered) + voice_chat_scheduled: VoiceChatScheduled = fields.Field(base=VoiceChatScheduled) voice_chat_started: VoiceChatStarted = fields.Field(base=VoiceChatStarted) voice_chat_ended: VoiceChatEnded = fields.Field(base=VoiceChatEnded) voice_chat_participants_invited: VoiceChatParticipantsInvited = fields.Field(base=VoiceChatParticipantsInvited) @@ -166,6 +168,8 @@ class Message(base.TelegramObject): return ContentType.PASSPORT_DATA if self.proximity_alert_triggered: return ContentType.PROXIMITY_ALERT_TRIGGERED + if self.voice_chat_scheduled: + return ContentType.VOICE_CHAT_SCHEDULED if self.voice_chat_started: return ContentType.VOICE_CHAT_STARTED if self.voice_chat_ended: @@ -3033,6 +3037,7 @@ 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 diff --git a/aiogram/types/voice_chat_scheduled.py b/aiogram/types/voice_chat_scheduled.py new file mode 100644 index 00000000..c134eb0f --- /dev/null +++ b/aiogram/types/voice_chat_scheduled.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from . import base +from . import fields +from .user import User + + +class VoiceChatScheduled(base.TelegramObject): + """ + This object represents a service message about a voice chat scheduled in the chat. + + https://core.telegram.org/bots/api#voicechatscheduled + """ + + start_date: datetime = fields.DateTimeField() diff --git a/docs/source/index.rst b/docs/source/index.rst index 809e195e..3631b150 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.1-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API From 08f0635afe07e0b9855f05526924af93ba45dd20 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Wed, 28 Apr 2021 01:24:34 +0300 Subject: [PATCH 05/24] Deep linking util fix (#569) * fix: deep linking util fixed and refactored * fix: wrong payload split * feat: check payload length --- aiogram/utils/deep_linking.py | 131 +++++++++++++++++--------- tests/test_utils/test_deep_linking.py | 37 +++++--- 2 files changed, 110 insertions(+), 58 deletions(-) diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py index acb105da..e8035d9a 100644 --- a/aiogram/utils/deep_linking.py +++ b/aiogram/utils/deep_linking.py @@ -1,10 +1,10 @@ """ Deep linking -Telegram bots have a deep linking mechanism, that allows for passing additional -parameters to the bot on startup. It could be a command that launches the bot — or -an auth token to connect the user's Telegram account to their account on some -external service. +Telegram bots have a deep linking mechanism, that allows for passing +additional parameters to the bot on startup. It could be a command that +launches the bot — or an auth token to connect the user's Telegram +account to their account on some external service. You can read detailed description in the source: https://core.telegram.org/bots#deep-linking @@ -16,86 +16,123 @@ Basic link example: .. code-block:: python from aiogram.utils.deep_linking import get_start_link - link = await get_start_link('foo') # result: 'https://t.me/MyBot?start=foo' + link = await get_start_link('foo') + + # result: 'https://t.me/MyBot?start=foo' Encoded link example: .. code-block:: python - from aiogram.utils.deep_linking import get_start_link, decode_payload - link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v' - # and decode it back: - payload = decode_payload('Zm9v') # result: 'foo' + from aiogram.utils.deep_linking import get_start_link + + link = await get_start_link('foo', encode=True) + # result: 'https://t.me/MyBot?start=Zm9v' + +Decode it back example: + .. code-block:: python + + from aiogram.utils.deep_linking import decode_payload + from aiogram.types import Message + + @dp.message_handler(commands=["start"]) + async def handler(message: Message): + args = message.get_args() + payload = decode_payload(args) + await message.answer(f"Your payload: {payload}") """ +import re +from base64 import urlsafe_b64decode, urlsafe_b64encode + +from ..bot import Bot + +BAD_PATTERN = re.compile(r"[^_A-z0-9-]") async def get_start_link(payload: str, encode=False) -> str: """ - Use this method to handy get 'start' deep link with your payload. - If you need to encode payload or pass special characters - set encode as True + Get 'start' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True :param payload: args passed with /start :param encode: encode payload with base64url :return: link """ - return await _create_link('start', payload, encode) + return await _create_link( + link_type="start", + payload=payload, + encode=encode, + ) async def get_startgroup_link(payload: str, encode=False) -> str: """ - Use this method to handy get 'startgroup' deep link with your payload. - If you need to encode payload or pass special characters - set encode as True + Get 'startgroup' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True :param payload: args passed with /start :param encode: encode payload with base64url :return: link """ - return await _create_link('startgroup', payload, encode) + return await _create_link( + link_type="startgroup", + payload=payload, + encode=encode, + ) async def _create_link(link_type, payload: str, encode=False): + """ + Create deep link. + + :param link_type: `start` or `startgroup` + :param payload: any string-convertible data + :param encode: pass True to encode the payload + :return: deeplink + """ bot = await _get_bot_user() - payload = filter_payload(payload) - if encode: - payload = encode_payload(payload) - return f'https://t.me/{bot.username}?{link_type}={payload}' - -def encode_payload(payload: str) -> str: - """ Encode payload with URL-safe base64url. """ - from base64 import urlsafe_b64encode - result: bytes = urlsafe_b64encode(payload.encode()) - return result.decode() - - -def decode_payload(payload: str) -> str: - """ Decode payload with URL-safe base64url. """ - from base64 import urlsafe_b64decode - result: bytes = urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)) - return result.decode() - - -def filter_payload(payload: str) -> str: - """ Convert payload to text and search for not allowed symbols. """ - import re - - # convert to string if not isinstance(payload, str): payload = str(payload) - # search for not allowed characters - if re.search(r'[^_A-z0-9-]', payload): - message = ('Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. ' - 'We recommend to encode parameters with binary and other ' - 'types of content.') + if encode: + payload = encode_payload(payload) + + if re.search(BAD_PATTERN, payload): + message = ( + "Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. " + "Pass `encode=True` or encode payload manually." + ) raise ValueError(message) - return payload + if len(payload) > 64: + message = "Payload must be up to 64 characters long." + raise ValueError(message) + + return f"https://t.me/{bot.username}?{link_type}={payload}" + + +def encode_payload(payload: str) -> str: + """Encode payload with URL-safe base64url.""" + payload = str(payload) + bytes_payload: bytes = urlsafe_b64encode(payload.encode()) + str_payload = bytes_payload.decode() + return str_payload.replace("=", "") + + +def decode_payload(payload: str) -> str: + """Decode payload with URL-safe base64url.""" + payload += "=" * (4 - len(payload) % 4) + result: bytes = urlsafe_b64decode(payload) + return result.decode() async def _get_bot_user(): - """ Get current user of bot. """ - from ..bot import Bot + """Get current user of bot.""" bot = Bot.get_current() return await bot.me diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py index a1d01e4e..f4aa14f1 100644 --- a/tests/test_utils/test_deep_linking.py +++ b/tests/test_utils/test_deep_linking.py @@ -1,7 +1,11 @@ import pytest -from aiogram.utils.deep_linking import decode_payload, encode_payload, filter_payload -from aiogram.utils.deep_linking import get_start_link, get_startgroup_link +from aiogram.utils.deep_linking import ( + decode_payload, + encode_payload, + get_start_link, + get_startgroup_link, +) from tests.types import dataset # enable asyncio mode @@ -17,9 +21,11 @@ PAYLOADS = [ WRONG_PAYLOADS = [ '@BotFather', + "Some:special$characters#=", 'spaces spaces spaces', 1234567890123456789.0, ] +USERNAME = dataset.USER["username"] @pytest.fixture(params=PAYLOADS, name='payload') @@ -47,7 +53,7 @@ def get_bot_user_fixture(monkeypatch): class TestDeepLinking: async def test_get_start_link(self, payload): link = await get_start_link(payload) - assert link == f'https://t.me/{dataset.USER["username"]}?start={payload}' + assert link == f'https://t.me/{USERNAME}?start={payload}' async def test_wrong_symbols(self, wrong_payload): with pytest.raises(ValueError): @@ -55,20 +61,29 @@ class TestDeepLinking: async def test_get_startgroup_link(self, payload): link = await get_startgroup_link(payload) - assert link == f'https://t.me/{dataset.USER["username"]}?startgroup={payload}' + assert link == f'https://t.me/{USERNAME}?startgroup={payload}' async def test_filter_encode_and_decode(self, payload): - _payload = filter_payload(payload) - encoded = encode_payload(_payload) + encoded = encode_payload(payload) decoded = decode_payload(encoded) assert decoded == str(payload) - async def test_get_start_link_with_encoding(self, payload): + async def test_get_start_link_with_encoding(self, wrong_payload): # define link - link = await get_start_link(payload, encode=True) + link = await get_start_link(wrong_payload, encode=True) # define reference link - payload = filter_payload(payload) - encoded_payload = encode_payload(payload) + encoded_payload = encode_payload(wrong_payload) - assert link == f'https://t.me/{dataset.USER["username"]}?start={encoded_payload}' + assert link == f'https://t.me/{USERNAME}?start={encoded_payload}' + + async def test_64_len_payload(self): + payload = "p" * 64 + link = await get_start_link(payload) + assert link + + async def test_too_long_payload(self): + payload = "p" * 65 + print(payload, len(payload)) + with pytest.raises(ValueError): + await get_start_link(payload) From 75e88f173ced1f29a4153c1f6f1446c7fb2787ee Mon Sep 17 00:00:00 2001 From: darksidecat <58224121+darksidecat@users.noreply.github.com> Date: Wed, 28 Apr 2021 01:25:31 +0300 Subject: [PATCH 06/24] Closes #548 (#549) Bot.create_chat_invite_link() Bot.edit_chat_invite_link() Bot.revoke_chat_invite_link() need to return types.ChatInviteLink, not dict --- aiogram/bot/bot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 0c72c050..68f4ff27 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1851,7 +1851,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): expire_date = prepare_arg(expire_date) payload = generate_payload(**locals()) - return await self.request(api.Methods.CREATE_CHAT_INVITE_LINK, payload) + result = await self.request(api.Methods.CREATE_CHAT_INVITE_LINK, payload) + return types.ChatInviteLink(**result) async def edit_chat_invite_link(self, chat_id: typing.Union[base.Integer, base.String], @@ -1887,7 +1888,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): expire_date = prepare_arg(expire_date) payload = generate_payload(**locals()) - return await self.request(api.Methods.EDIT_CHAT_INVITE_LINK, payload) + result = await self.request(api.Methods.EDIT_CHAT_INVITE_LINK, payload) + return types.ChatInviteLink(**result) async def revoke_chat_invite_link(self, chat_id: typing.Union[base.Integer, base.String], @@ -1908,7 +1910,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ payload = generate_payload(**locals()) - return await self.request(api.Methods.REVOKE_CHAT_INVITE_LINK, payload) + result = await self.request(api.Methods.REVOKE_CHAT_INVITE_LINK, payload) + return types.ChatInviteLink(**result) async def set_chat_photo(self, chat_id: typing.Union[base.Integer, base.String], photo: base.InputFile) -> base.Boolean: From ba095f0b9f1d0aa61538815b1babd4066205514f Mon Sep 17 00:00:00 2001 From: Oleg A Date: Wed, 28 Apr 2021 01:28:34 +0300 Subject: [PATCH 07/24] i18n get_locale without User (#546) * fix: #544 return locale None if User is absent * fix: #544 fixed typing * fix: #544 User is Optional * style: minor docs styling * fix: explicit None return + typing * fix: typing --- aiogram/contrib/middlewares/i18n.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index bb6d8003..651b77de 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -1,7 +1,7 @@ import gettext import os from contextvars import ContextVar -from typing import Any, Dict, Tuple +from typing import Any, Dict, Tuple, Optional from babel import Locale from babel.support import LazyProxy @@ -119,22 +119,24 @@ class I18nMiddleware(BaseMiddleware): return LazyProxy(self.gettext, singular, plural, n, locale, enable_cache=enable_cache) # noinspection PyMethodMayBeStatic,PyUnusedLocal - async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: + async def get_user_locale(self, action: str, args: Tuple[Any]) -> Optional[str]: """ User locale getter - You can override the method if you want to use different way of getting user language. + You can override the method if you want to use different way of + getting user language. :param action: event name :param args: event arguments - :return: locale name + :return: locale name or None """ - user: types.User = types.User.get_current() - locale: Locale = user.locale + user: Optional[types.User] = types.User.get_current() + locale: Optional[Locale] = user.locale if user else None if locale: *_, data = args language = data['locale'] = locale.language return language + return None async def trigger(self, action, args): """ From 4120408aa314bd53306805da508bd292323c3706 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Wed, 28 Apr 2021 01:28:53 +0300 Subject: [PATCH 08/24] Set state via storage (#542) * refactor: simplified check_address (removed redundant states check) * refactor: FSM resolve_state become public, removed redundant elif * fix: resolve `filters.State` on `set_state` * refactor: moved state resolution to storage * fix: return default state on get_state --- aiogram/contrib/fsm_storage/memory.py | 4 +-- aiogram/contrib/fsm_storage/mongo.py | 15 +++++--- aiogram/contrib/fsm_storage/redis.py | 11 +++--- aiogram/contrib/fsm_storage/rethinkdb.py | 13 +++++-- aiogram/dispatcher/storage.py | 44 ++++++++++++++---------- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/aiogram/contrib/fsm_storage/memory.py b/aiogram/contrib/fsm_storage/memory.py index 2940f3fa..e1d6bdc0 100644 --- a/aiogram/contrib/fsm_storage/memory.py +++ b/aiogram/contrib/fsm_storage/memory.py @@ -35,7 +35,7 @@ class MemoryStorage(BaseStorage): user: typing.Union[str, int, None] = None, default: typing.Optional[str] = None) -> typing.Optional[str]: chat, user = self.resolve_address(chat=chat, user=user) - return self.data[chat][user]['state'] + return self.data[chat][user].get("state", self.resolve_state(default)) async def get_data(self, *, chat: typing.Union[str, int, None] = None, @@ -58,7 +58,7 @@ class MemoryStorage(BaseStorage): user: typing.Union[str, int, None] = None, state: typing.AnyStr = None): chat, user = self.resolve_address(chat=chat, user=user) - self.data[chat][user]['state'] = state + self.data[chat][user]['state'] = self.resolve_state(state) async def set_data(self, *, chat: typing.Union[str, int, None] = None, diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index f810a3eb..992e2e70 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -65,7 +65,7 @@ class MongoStorage(BaseStorage): try: self._mongo = AsyncIOMotorClient(self._uri) except pymongo.errors.ConfigurationError as e: - if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]: + if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]: import logging logger = logging.getLogger("aiogram") logger.warning("Run `pip install dnspython==1.16.0` in order to fix ConfigurationError. More information: https://github.com/mongodb/mongo-python-driver/pull/423#issuecomment-528998245") @@ -114,7 +114,9 @@ class MongoStorage(BaseStorage): async def wait_closed(self): return True - async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + async def set_state(self, *, + chat: Union[str, int, None] = None, + user: Union[str, int, None] = None, state: Optional[AnyStr] = None): chat, user = self.check_address(chat=chat, user=user) db = await self.get_db() @@ -122,8 +124,11 @@ class MongoStorage(BaseStorage): if state is None: await db[STATE].delete_one(filter={'chat': chat, 'user': user}) else: - await db[STATE].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'state': state}}, upsert=True) + await db[STATE].update_one( + filter={'chat': chat, 'user': user}, + update={'$set': {'state': self.resolve_state(state)}}, + upsert=True, + ) async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, default: Optional[str] = None) -> Optional[str]: @@ -131,7 +136,7 @@ class MongoStorage(BaseStorage): db = await self.get_db() result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) - return result.get('state') if result else default + return result.get('state') if result else self.resolve_state(default) async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, data: Dict = None): diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 74dd736c..01a0fe5c 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -118,16 +118,19 @@ class RedisStorage(BaseStorage): 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]: record = await self.get_record(chat=chat, user=user) - return record['state'] + return record.get('state', 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[str] = None) -> typing.Dict: record = await self.get_record(chat=chat, user=user) return record['data'] - async def set_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, + async def set_state(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None, state: typing.Optional[typing.AnyStr] = None): record = await self.get_record(chat=chat, user=user) + state = self.resolve_state(state) await self.set_record(chat=chat, user=user, state=state, data=record['data']) async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, @@ -274,7 +277,7 @@ class RedisStorage2(BaseStorage): 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 None + return await redis.get(key, encoding='utf8') 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: @@ -294,7 +297,7 @@ class RedisStorage2(BaseStorage): if state is None: await redis.delete(key) else: - await redis.set(key, state, expire=self._state_ttl) + await redis.set(key, self.resolve_state(state), expire=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): diff --git a/aiogram/contrib/fsm_storage/rethinkdb.py b/aiogram/contrib/fsm_storage/rethinkdb.py index 5bb9062a..c600074e 100644 --- a/aiogram/contrib/fsm_storage/rethinkdb.py +++ b/aiogram/contrib/fsm_storage/rethinkdb.py @@ -95,7 +95,9 @@ class RethinkDBStorage(BaseStorage): default: typing.Optional[str] = None) -> typing.Optional[str]: chat, user = map(str, self.check_address(chat=chat, user=user)) async with self.connection() as conn: - return await r.table(self._table).get(chat)[user]['state'].default(default or None).run(conn) + return await r.table(self._table).get(chat)[user]['state'].default( + self.resolve_state(default) or None + ).run(conn) async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[str] = None) -> typing.Dict: @@ -103,11 +105,16 @@ class RethinkDBStorage(BaseStorage): async with self.connection() as conn: return await r.table(self._table).get(chat)[user]['data'].default(default or {}).run(conn) - async def set_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, + async def set_state(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None, state: typing.Optional[typing.AnyStr] = None): chat, user = map(str, self.check_address(chat=chat, user=user)) async with self.connection() as conn: - await r.table(self._table).insert({'id': chat, user: {'state': state}}, conflict="update").run(conn) + await r.table(self._table).insert( + {'id': chat, user: {'state': self.resolve_state(state)}}, + conflict="update", + ).run(conn) async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None): diff --git a/aiogram/dispatcher/storage.py b/aiogram/dispatcher/storage.py index 74492361..eb248e34 100644 --- a/aiogram/dispatcher/storage.py +++ b/aiogram/dispatcher/storage.py @@ -40,24 +40,27 @@ class BaseStorage: @classmethod def check_address(cls, *, chat: typing.Union[str, int, None] = None, - user: typing.Union[str, int, None] = None) -> (typing.Union[str, int], typing.Union[str, int]): + user: typing.Union[str, int, None] = None, + ) -> (typing.Union[str, int], typing.Union[str, int]): """ In all storage's methods chat or user is always required. If one of them is not provided, you have to set missing value based on the provided one. This method performs the check described above. - :param chat: - :param user: + :param chat: chat_id + :param user: user_id :return: """ if chat is None and user is None: raise ValueError('`user` or `chat` parameter is required but no one is provided!') - if user is None and chat is not None: + if user is None: user = chat - elif user is not None and chat is None: + + elif chat is None: chat = user + return chat, user async def get_state(self, *, @@ -270,6 +273,21 @@ class BaseStorage: """ await self.set_data(chat=chat, user=user, data={}) + @staticmethod + def resolve_state(value): + from .filters.state import State + + if value is None: + return + + if isinstance(value, str): + return value + + if isinstance(value, State): + return value.state + + return str(value) + class FSMContext: def __init__(self, storage, chat, user): @@ -279,20 +297,8 @@ class FSMContext: def proxy(self): return FSMContextProxy(self) - @staticmethod - def _resolve_state(value): - from .filters.state import State - - if value is None: - return - elif isinstance(value, str): - return value - elif isinstance(value, State): - return value.state - return str(value) - async def get_state(self, default: typing.Optional[str] = None) -> typing.Optional[str]: - return await self.storage.get_state(chat=self.chat, user=self.user, default=self._resolve_state(default)) + return await self.storage.get_state(chat=self.chat, user=self.user, default=default) async def get_data(self, default: typing.Optional[str] = None) -> typing.Dict: return await self.storage.get_data(chat=self.chat, user=self.user, default=default) @@ -301,7 +307,7 @@ class FSMContext: await self.storage.update_data(chat=self.chat, user=self.user, data=data, **kwargs) async def set_state(self, state: typing.Optional[typing.AnyStr] = None): - await self.storage.set_state(chat=self.chat, user=self.user, state=self._resolve_state(state)) + await self.storage.set_state(chat=self.chat, user=self.user, state=state) async def set_data(self, data: typing.Dict = None): await self.storage.set_data(chat=self.chat, user=self.user, data=data) From 35bf18cf5ac4fdf75eccd6d23873309589beef35 Mon Sep 17 00:00:00 2001 From: Googleplex Date: Thu, 29 Apr 2021 04:37:59 +0800 Subject: [PATCH 09/24] fix: builtin command filter args (#556) (#558) * fix: builtin command filter args * fix: use string for command arguments * fix: text property of command object Co-authored-by: evgfilim1 Co-authored-by: evgfilim1 --- aiogram/dispatcher/filters/builtin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index c32c53be..457de182 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, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union from babel.support import LazyProxy @@ -110,7 +110,8 @@ class Command(Filter): if not text: return False - full_command = text.split()[0] + full_command, *args_list = text.split(maxsplit=1) + args = args_list[0] if args_list else None prefix, (command, _, mention) = full_command[0], full_command[1:].partition('@') if not ignore_mention and mention and (await message.bot.me).username.lower() != mention.lower(): @@ -120,7 +121,7 @@ class Command(Filter): if (command.lower() if ignore_case else command) not in commands: return False - return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention)} + return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention, args=args)} @dataclass class CommandObj: From 1f57a40c45f6d94d010e928bd18ab16a9720df69 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 28 Apr 2021 23:39:36 +0300 Subject: [PATCH 10/24] Correctly use `provider_data` argument --- aiogram/bot/bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 68f4ff27..07d4b963 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -2941,6 +2941,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ prices = prepare_arg([price.to_python() if hasattr(price, 'to_python') else price for price in prices]) reply_markup = prepare_arg(reply_markup) + provider_data = prepare_arg(provider_data) payload_ = generate_payload(**locals()) result = await self.request(api.Methods.SEND_INVOICE, payload_) From d5a4c0c4afc6f03b9de19f7f68f852c9188cf072 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 28 Apr 2021 23:41:07 +0300 Subject: [PATCH 11/24] Bump version --- aiogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index a77ecdc0..3471ddcd 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.13.0' +__version__ = '2.13' __api_version__ = '5.2' From 405add31abf76de6896bf43ee3826306053d4916 Mon Sep 17 00:00:00 2001 From: dashedman <64865196+dashedman@users.noreply.github.com> Date: Tue, 11 May 2021 23:39:17 +0300 Subject: [PATCH 12/24] fix get_full_command for messages with caption (#576) * fix get_full_command for messages with caption * change to more cleaner method --- aiogram/types/message.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index ce8395d2..5eb96618 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -194,7 +194,8 @@ class Message(base.TelegramObject): :return: bool """ - return self.text and self.text.startswith("/") + text = self.text or self.caption + return text and text.startswith("/") def get_full_command(self) -> typing.Optional[typing.Tuple[str, str]]: """ @@ -203,8 +204,9 @@ class Message(base.TelegramObject): :return: tuple of (command, args) """ if self.is_command(): - command, *args = self.text.split(maxsplit=1) - args = args[-1] if args else "" + text = self.text or self.caption + command, *args = text.split(maxsplit=1) + args = args[0] if args else "" return command, args def get_command(self, pure=False) -> typing.Optional[str]: @@ -271,7 +273,7 @@ class Message(base.TelegramObject): :return: str """ - + if self.chat.type == ChatType.PRIVATE: raise TypeError("Invalid chat type!") url = "https://t.me/" @@ -1420,7 +1422,7 @@ class Message(base.TelegramObject): allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) - + async def answer_chat_action( self, action: base.String, From 0e28756a103c63d807ec55d8afe0de92b300d856 Mon Sep 17 00:00:00 2001 From: pigeonburger <70826123+pigeonburger@users.noreply.github.com> Date: Mon, 17 May 2021 20:17:48 +1000 Subject: [PATCH 13/24] Update README.md (#586) --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ae44c524..2d3c0545 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ import asyncio from aiogram import Bot +BOT_TOKEN = "" async def main(): - bot = Bot(token=BOT-TOKEN) + bot = Bot(token=BOT_TOKEN) try: me = await bot.get_me() @@ -48,6 +49,8 @@ asyncio.run(main()) import asyncio from aiogram import Bot, Dispatcher, types +BOT_TOKEN = "" + async def start_handler(event: types.Message): await event.answer( f"Hello, {event.from_user.get_mention(as_html=True)} 👋!", @@ -55,7 +58,7 @@ async def start_handler(event: types.Message): ) async def main(): - bot = Bot(token=BOT-TOKEN) + bot = Bot(token=BOT_TOKEN) try: disp = Dispatcher(bot=bot) disp.register_message_handler(start_handler, commands={"start", "restart"}) From 5f6e5a646b9a86bb1146244548a0d688bfaf4867 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Tue, 25 May 2021 10:12:52 +0300 Subject: [PATCH 14/24] Fix default updates (#592) * fix: default updates * fix: removed redundant cache import --- aiogram/types/update.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 7cf616bb..e2fd3a55 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -1,7 +1,5 @@ from __future__ import annotations -from functools import lru_cache - from . import base from . import fields from .callback_query import CallbackQuery @@ -76,7 +74,5 @@ class AllowedUpdates(helper.Helper): ) @classmethod - @lru_cache(1) def default(cls): - excluded = cls.CHAT_MEMBER + cls.MY_CHAT_MEMBER - return list(filter(lambda item: item not in excluded, cls.all())) + return [] From 02cd42a3397b635080131b3875b55952606c3099 Mon Sep 17 00:00:00 2001 From: p82o <64755699+p82o@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:08:59 +0300 Subject: [PATCH 15/24] Update text_decorations.py (#597) --- aiogram/utils/text_decorations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 09484e3c..40fe296b 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -185,7 +185,7 @@ class MarkdownDecoration(TextDecoration): return f"`{value}`" def pre(self, value: str) -> str: - return f"```{value}```" + return f"```\n{value}\n```" def pre_language(self, value: str, language: str) -> str: return f"```{language}\n{value}\n```" From 7eb32785f6c8bbc464eea5233bd3ea84cd645fb1 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sun, 13 Jun 2021 01:20:26 +0300 Subject: [PATCH 16/24] fix: input media caption_entities (#583) --- aiogram/types/input_media.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index 4d03daec..6804b460 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -28,6 +28,7 @@ class InputMedia(base.TelegramObject): thumb: typing.Union[base.InputFile, base.String] = fields.Field(alias='thumb', on_change='_thumb_changed') caption: base.String = fields.Field() parse_mode: base.String = fields.Field() + caption_entities: typing.List[MessageEntity] = fields.ListField(base=MessageEntity) def __init__(self, *args, **kwargs): self._thumb_file = None From 0b1c22b7b0c317eec8130cd223f422c72736a195 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sun, 13 Jun 2021 01:22:47 +0300 Subject: [PATCH 17/24] =?UTF-8?q?=D0=A1leanup=20storage=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove key from storage, when not needed * fix redis1 delete * fix new line * check reset * fix redis store close check --- aiogram/contrib/fsm_storage/memory.py | 9 +++ aiogram/contrib/fsm_storage/redis.py | 20 ++++-- dev_requirements.txt | 1 + tests/contrib/fsm_storage/test_redis.py | 33 ---------- tests/contrib/fsm_storage/test_storage.py | 79 +++++++++++++++++++++++ 5 files changed, 103 insertions(+), 39 deletions(-) delete mode 100644 tests/contrib/fsm_storage/test_redis.py create mode 100644 tests/contrib/fsm_storage/test_storage.py diff --git a/aiogram/contrib/fsm_storage/memory.py b/aiogram/contrib/fsm_storage/memory.py index e1d6bdc0..8950aa8e 100644 --- a/aiogram/contrib/fsm_storage/memory.py +++ b/aiogram/contrib/fsm_storage/memory.py @@ -66,6 +66,7 @@ class MemoryStorage(BaseStorage): data: typing.Dict = None): chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['data'] = copy.deepcopy(data) + self._cleanup(chat, user) async def reset_state(self, *, chat: typing.Union[str, int, None] = None, @@ -74,6 +75,7 @@ class MemoryStorage(BaseStorage): await self.set_state(chat=chat, user=user, state=None) if with_data: await self.set_data(chat=chat, user=user, data={}) + self._cleanup(chat, user) def has_bucket(self): return True @@ -91,6 +93,7 @@ class MemoryStorage(BaseStorage): bucket: typing.Dict = None): chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['bucket'] = copy.deepcopy(bucket) + self._cleanup(chat, user) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, @@ -100,3 +103,9 @@ class MemoryStorage(BaseStorage): bucket = {} chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['bucket'].update(bucket, **kwargs) + + def _cleanup(self, chat, user): + if self.data[chat][user] == {'state': None, 'data': {}, 'bucket': {}}: + del self.data[chat][user] + if not self.data[chat]: + del self.data[chat] diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 01a0fe5c..5d0b762c 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -110,10 +110,12 @@ class RedisStorage(BaseStorage): chat, user = self.check_address(chat=chat, user=user) addr = f"fsm:{chat}:{user}" - record = {'state': state, 'data': data, 'bucket': bucket} - conn = await self.redis() - await conn.execute('SET', addr, json.dumps(record)) + if state is None and data == bucket == {}: + await conn.execute('DEL', addr) + else: + record = {'state': state, 'data': data, 'bucket': bucket} + await conn.execute('SET', addr, json.dumps(record)) 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]: @@ -222,7 +224,7 @@ class RedisStorage2(BaseStorage): await dp.storage.wait_closed() """ - def __init__(self, host: str = 'localhost', port=6379, db=None, password=None, + 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, @@ -304,7 +306,10 @@ class RedisStorage2(BaseStorage): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) redis = await self.redis() - await redis.set(key, json.dumps(data), expire=self._data_ttl) + if data: + await redis.set(key, json.dumps(data), expire=self._data_ttl) + else: + await redis.delete(key) async def update_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None, **kwargs): @@ -332,7 +337,10 @@ class RedisStorage2(BaseStorage): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) redis = await self.redis() - await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) + if bucket: + await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) + else: + await redis.delete(key) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, diff --git a/dev_requirements.txt b/dev_requirements.txt index ef5272af..26e410aa 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -16,3 +16,4 @@ aiohttp-socks>=0.3.4 rethinkdb>=2.4.1 coverage==4.5.3 motor>=2.2.0 +pytest-lazy-fixture==0.6.* diff --git a/tests/contrib/fsm_storage/test_redis.py b/tests/contrib/fsm_storage/test_redis.py deleted file mode 100644 index 527c905e..00000000 --- a/tests/contrib/fsm_storage/test_redis.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from aiogram.contrib.fsm_storage.redis import RedisStorage2 - - -@pytest.fixture() -async def store(redis_options): - s = RedisStorage2(**redis_options) - try: - yield s - finally: - conn = await s.redis() - await conn.flushdb() - await s.close() - await s.wait_closed() - - -@pytest.mark.redis -class TestRedisStorage2: - @pytest.mark.asyncio - async def test_set_get(self, store): - assert await store.get_data(chat='1234') == {} - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - - @pytest.mark.asyncio - async def test_close_and_open_connection(self, store): - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - pool_id = id(store._redis) - await store.close() - 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/contrib/fsm_storage/test_storage.py b/tests/contrib/fsm_storage/test_storage.py new file mode 100644 index 00000000..0cde2de2 --- /dev/null +++ b/tests/contrib/fsm_storage/test_storage.py @@ -0,0 +1,79 @@ +import pytest + +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.contrib.fsm_storage.redis import RedisStorage2, RedisStorage + + +@pytest.fixture() +@pytest.mark.redis +async def redis_store(redis_options): + s = RedisStorage(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.execute('FLUSHDB') + await s.close() + await s.wait_closed() + + +@pytest.fixture() +@pytest.mark.redis +async def redis_store2(redis_options): + s = RedisStorage2(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.flushdb() + await s.close() + await s.wait_closed() + + +@pytest.fixture() +async def memory_store(): + yield MemoryStorage() + + +@pytest.mark.parametrize( + "store", [ + pytest.lazy_fixture('redis_store'), + pytest.lazy_fixture('redis_store2'), + pytest.lazy_fixture('memory_store'), + ] +) +class TestStorage: + @pytest.mark.asyncio + async def test_set_get(self, store): + assert await store.get_data(chat='1234') == {} + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + + @pytest.mark.asyncio + async def test_reset(self, store): + await store.set_data(chat='1234', data={'foo': 'bar'}) + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + @pytest.mark.asyncio + async def test_reset_empty(self, store): + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + +@pytest.mark.parametrize( + "store", [ + pytest.lazy_fixture('redis_store'), + pytest.lazy_fixture('redis_store2'), + ] +) +class TestRedisStorage2: + @pytest.mark.asyncio + async def test_close_and_open_connection(self, store): + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + pool_id = id(store._redis) + await store.close() + assert await store.get_data(chat='1234') == { + 'foo': 'bar'} # new pool was opened at this point + assert id(store._redis) != pool_id From f20e6ca0bde0453924fe4882b1f999ea91a1df1d Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sun, 13 Jun 2021 01:22:57 +0300 Subject: [PATCH 18/24] fix: GroupDeactivated exception text update (#598) --- aiogram/utils/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index 9a1606a6..e3a1f313 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -368,7 +368,7 @@ class WrongFileIdentifier(BadRequest): class GroupDeactivated(BadRequest): - match = 'group is deactivated' + match = 'Group chat was deactivated' class PhotoAsInputFileRequired(BadRequest): From c42b7e4b0d72503ea1b63a64ff9e0159bc4b68ae Mon Sep 17 00:00:00 2001 From: Biorobot1337 <83316072+genagorkin1@users.noreply.github.com> Date: Mon, 14 Jun 2021 02:26:27 +0500 Subject: [PATCH 19/24] Update message.py (#603) Fixed syntax bug, added comma --- aiogram/types/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 5eb96618..c95b14b1 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -2933,7 +2933,7 @@ class Message(base.TelegramObject): question=self.poll.question, options=[option.text for option in self.poll.options], is_anonymous=self.poll.is_anonymous, - allows_multiple_answers=self.poll.allows_multiple_answers + allows_multiple_answers=self.poll.allows_multiple_answers, **kwargs, ) elif self.dice: From e70a76ff63c0d3db0d1d15e26c6f0edbaaad9670 Mon Sep 17 00:00:00 2001 From: Biorobot1337 <83316072+genagorkin1@users.noreply.github.com> Date: Tue, 22 Jun 2021 01:15:01 +0500 Subject: [PATCH 20/24] Mongo storage cleanup (#609) * Update message.py Fixed syntax bug, added comma * Cleanup mongodb storage Cleaning up blank documents in DATA collection --- aiogram/contrib/fsm_storage/mongo.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index 992e2e70..ab7d3176 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -142,9 +142,11 @@ class MongoStorage(BaseStorage): data: Dict = None): chat, user = self.check_address(chat=chat, user=user) db = await self.get_db() - - await db[DATA].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'data': data}}, upsert=True) + if not data: + await db[DATA].delete_one(filter={'chat': chat, 'user': user}) + else: + await db[DATA].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'data': data}}, upsert=True) async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, default: Optional[dict] = None) -> Dict: From f18e4491c25823644c926447b5fb42ad697992ad Mon Sep 17 00:00:00 2001 From: Oleg A Date: Sun, 4 Jul 2021 23:52:55 +0300 Subject: [PATCH 21/24] Telegram API 5.3 (#610) * docs: api version update * feat: personalized commands * feat: custom placeholders * refactor: ChatMember split * fix: old names for ChatMemberStatus * refactor: renamed kickChatMember to banChatMember * style: align params * refactor: renamed getChatMembersCount to getChatMemberCount (#614) * feat: resolve ChatMember * refactor: renamed BotCommandScopeTypes (similar to code style) * refactor: resolve is a static method * Construct BotCommandScope from type * Make BotCommandScope classmethod instead of method * Use classmethod for ChatMember resolve method Co-authored-by: Hoi Dmytro Co-authored-by: Alex Root Junior --- README.md | 2 +- README.rst | 2 +- aiogram/__init__.py | 4 +- aiogram/bot/api.py | 7 +- aiogram/bot/bot.py | 160 +++++++++++++++++++------ aiogram/types/__init__.py | 13 ++ aiogram/types/bot_command_scope.py | 121 +++++++++++++++++++ aiogram/types/chat.py | 32 ++--- aiogram/types/chat_member.py | 186 ++++++++++++++++++++++------- aiogram/types/force_reply.py | 25 ++-- aiogram/types/reply_keyboard.py | 17 ++- docs/source/index.rst | 2 +- tests/test_bot.py | 8 +- tests/types/test_chat_member.py | 2 +- 14 files changed, 455 insertions(+), 126 deletions(-) create mode 100644 aiogram/types/bot_command_scope.py diff --git a/README.md b/README.md index 2d3c0545..fca118a0 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.2-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.3-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 caf6149c..6df651a2 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.2-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-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 3471ddcd..2d852a7e 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.13' -__api_version__ = '5.2' +__version__ = '2.14' +__api_version__ = '5.3' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 38cbee89..1bf00d47 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.2 + List is updated to Bot API 5.3 """ mode = HelperMode.lowerCamelCase @@ -225,6 +225,7 @@ class Methods(Helper): GET_USER_PROFILE_PHOTOS = Item() # getUserProfilePhotos GET_FILE = Item() # getFile KICK_CHAT_MEMBER = Item() # kickChatMember + BAN_CHAT_MEMBER = Item() # banChatMember UNBAN_CHAT_MEMBER = Item() # unbanChatMember RESTRICT_CHAT_MEMBER = Item() # restrictChatMember PROMOTE_CHAT_MEMBER = Item() # promoteChatMember @@ -244,12 +245,14 @@ class Methods(Helper): LEAVE_CHAT = Item() # leaveChat GET_CHAT = Item() # getChat GET_CHAT_ADMINISTRATORS = Item() # getChatAdministrators - GET_CHAT_MEMBERS_COUNT = Item() # getChatMembersCount + GET_CHAT_MEMBER_COUNT = Item() # getChatMemberCount + GET_CHAT_MEMBERS_COUNT = Item() # getChatMembersCount (renamed to getChatMemberCount) GET_CHAT_MEMBER = Item() # getChatMember SET_CHAT_STICKER_SET = Item() # setChatStickerSet DELETE_CHAT_STICKER_SET = Item() # deleteChatStickerSet ANSWER_CALLBACK_QUERY = Item() # answerCallbackQuery SET_MY_COMMANDS = Item() # setMyCommands + DELETE_MY_COMMANDS = Item() # deleteMyCommands GET_MY_COMMANDS = Item() # getMyCommands # Updating messages diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 07d4b963..435def3e 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1562,41 +1562,42 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.GET_FILE, payload) return types.File(**result) - async def kick_chat_member(self, - chat_id: typing.Union[base.Integer, base.String], - user_id: base.Integer, - until_date: typing.Union[base.Integer, datetime.datetime, - datetime.timedelta, None] = None, - revoke_messages: typing.Optional[base.Boolean] = None, - ) -> base.Boolean: + async def ban_chat_member(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + until_date: typing.Union[base.Integer, datetime.datetime, + datetime.timedelta, None] = None, + revoke_messages: typing.Optional[base.Boolean] = None, + ) -> base.Boolean: """ - Use this method to kick a user from a group, a supergroup or a channel. - In the case of supergroups and channels, the user will not be able to return - to the chat on their own using invite links, etc., unless unbanned first. + Use this method to ban a user in a group, a supergroup or a + channel. In the case of supergroups and channels, the user will + not be able to return to the chat on their own using invite + links, etc., unless unbanned first. The bot must be an + administrator in the chat for this to work and must have the + appropriate admin rights. Returns True on success. - The bot must be an administrator in the chat for this to work and must have - the appropriate admin rights. + Source: https://core.telegram.org/bots/api#banchatmember - Source: https://core.telegram.org/bots/api#kickchatmember - - :param chat_id: Unique identifier for the target group or username of the - target supergroup or channel (in the format @channelusername) + :param chat_id: Unique identifier for the target group or + username of the target supergroup or channel (in the format + @channelusername) :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :param until_date: Date when the user will be unbanned. If user is banned - for more than 366 days or less than 30 seconds from the current time they - are considered to be banned forever. Applied for supergroups and channels - only. - :type until_date: :obj:`typing.Union[base.Integer, datetime.datetime, - datetime.timedelta, None]` + :param until_date: Date when the user will be unbanned, unix + time. If user is banned for more than 366 days or less than + 30 seconds from the current time they are considered to be + banned forever. Applied for supergroups and channels only. + :type until_date: :obj:`typing.Union[base.Integer, + datetime.datetime, datetime.timedelta, None]` - :param revoke_messages: Pass True to delete all messages from the chat for - the user that is being removed. If False, the user will be able to see - messages in the group that were sent before the user was removed. Always - True for supergroups and channels. + :param revoke_messages: Pass True to delete all messages from + the chat for the user that is being removed. If False, the user + will be able to see messages in the group that were sent before + the user was removed. Always True for supergroups and channels. :type revoke_messages: :obj:`typing.Optional[base.Boolean]` :return: Returns True on success @@ -1605,7 +1606,22 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): until_date = prepare_arg(until_date) payload = generate_payload(**locals()) - return await self.request(api.Methods.KICK_CHAT_MEMBER, payload) + return await self.request(api.Methods.BAN_CHAT_MEMBER, payload) + + async def kick_chat_member(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + until_date: typing.Union[base.Integer, datetime.datetime, + datetime.timedelta, None] = None, + revoke_messages: typing.Optional[base.Boolean] = None, + ) -> base.Boolean: + """Renamed to ban_chat_member.""" + return await self.ban_chat_member( + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + revoke_messages=revoke_messages, + ) async def unban_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -2130,13 +2146,13 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_CHAT_ADMINISTRATORS, payload) - return [types.ChatMember(**chatmember) for chatmember in result] + return [types.ChatMember.resolve(**chat_member) for chat_member in result] - async def get_chat_members_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: + async def get_chat_member_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: """ Use this method to get the number of members in a chat. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` @@ -2145,7 +2161,11 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ payload = generate_payload(**locals()) - return await self.request(api.Methods.GET_CHAT_MEMBERS_COUNT, payload) + return await self.request(api.Methods.GET_CHAT_MEMBER_COUNT, payload) + + async def get_chat_members_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: + """Renamed to get_chat_member_count.""" + return await self.get_chat_member_count(chat_id) async def get_chat_member(self, chat_id: typing.Union[base.Integer, base.String], user_id: base.Integer) -> types.ChatMember: @@ -2164,7 +2184,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_CHAT_MEMBER, payload) - return types.ChatMember(**result) + return types.ChatMember.resolve(**result) async def set_chat_sticker_set(self, chat_id: typing.Union[base.Integer, base.String], sticker_set_name: base.String) -> base.Boolean: @@ -2241,31 +2261,95 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.ANSWER_CALLBACK_QUERY, payload) - async def set_my_commands(self, commands: typing.List[types.BotCommand]) -> base.Boolean: + async def set_my_commands(self, + commands: typing.List[types.BotCommand], + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> base.Boolean: """ Use this method to change the list of the bot's commands. Source: https://core.telegram.org/bots/api#setmycommands - :param commands: A JSON-serialized list of bot commands to be set as the list of the bot's commands. - At most 100 commands can be specified. + :param commands: A JSON-serialized list of bot commands to be + set as the list of the bot's commands. At most 100 commands + can be specified. :type commands: :obj: `typing.List[types.BotCommand]` + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + :return: Returns True on success. :rtype: :obj:`base.Boolean` """ commands = prepare_arg(commands) + scope = prepare_arg(scope) payload = generate_payload(**locals()) return await self.request(api.Methods.SET_MY_COMMANDS, payload) - async def get_my_commands(self) -> typing.List[types.BotCommand]: + async def delete_my_commands(self, + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> base.Boolean: """ - Use this method to get the current list of the bot's commands. + Use this method to delete the list of the bot's commands for the + given scope and user language. After deletion, higher level + commands will be shown to affected users. + + Source: https://core.telegram.org/bots/api#deletemycommands + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + + :return: Returns True on success. + :rtype: :obj:`base.Boolean` + """ + scope = prepare_arg(scope) + payload = generate_payload(**locals()) + + return await self.request(api.Methods.DELETE_MY_COMMANDS, payload) + + async def get_my_commands(self, + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> typing.List[types.BotCommand]: + """ + Use this method to get the current list of the bot's commands + for the given scope and user language. Returns Array of + BotCommand on success. If commands aren't set, an empty list is + returned. Source: https://core.telegram.org/bots/api#getmycommands - :return: Returns Array of BotCommand on success. + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + + :return: Returns Array of BotCommand on success or empty list. :rtype: :obj:`typing.List[types.BotCommand]` """ + scope = prepare_arg(scope) payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_MY_COMMANDS, payload) diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 90909e81..a9e6af8c 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -4,6 +4,10 @@ from .animation import Animation from .audio import Audio from .auth_widget_data import AuthWidgetData from .bot_command import BotCommand +from .bot_command_scope import BotCommandScope, BotCommandScopeAllChatAdministrators, \ + BotCommandScopeAllGroupChats, BotCommandScopeAllPrivateChats, BotCommandScopeChat, \ + BotCommandScopeChatAdministrators, BotCommandScopeChatMember, \ + BotCommandScopeDefault, BotCommandScopeType from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat, ChatActions, ChatType @@ -82,6 +86,15 @@ __all__ = ( 'Audio', 'AuthWidgetData', 'BotCommand', + 'BotCommandScope', + 'BotCommandScopeAllChatAdministrators', + 'BotCommandScopeAllGroupChats', + 'BotCommandScopeAllPrivateChats', + 'BotCommandScopeChat', + 'BotCommandScopeChatAdministrators', + 'BotCommandScopeChatMember', + 'BotCommandScopeDefault', + 'BotCommandScopeType', 'CallbackGame', 'CallbackQuery', 'Chat', diff --git a/aiogram/types/bot_command_scope.py b/aiogram/types/bot_command_scope.py new file mode 100644 index 00000000..e3091a7e --- /dev/null +++ b/aiogram/types/bot_command_scope.py @@ -0,0 +1,121 @@ +import typing + +from . import base, fields +from ..utils import helper + + +class BotCommandScopeType(helper.Helper): + mode = helper.HelperMode.lowercase + + DEFAULT = helper.Item() # default + ALL_PRIVATE_CHATS = helper.Item() # all_private_chats + ALL_GROUP_CHATS = helper.Item() # all_group_chats + ALL_CHAT_ADMINISTRATORS = helper.Item() # all_chat_administrators + CHAT = helper.Item() # chat + CHAT_ADMINISTRATORS = helper.Item() # chat_administrators + CHAT_MEMBER = helper.Item() # chat_member + + +class BotCommandScope(base.TelegramObject): + """ + This object represents the scope to which bot commands are applied. + Currently, the following 7 scopes are supported: + BotCommandScopeDefault + BotCommandScopeAllPrivateChats + BotCommandScopeAllGroupChats + BotCommandScopeAllChatAdministrators + BotCommandScopeChat + BotCommandScopeChatAdministrators + BotCommandScopeChatMember + + https://core.telegram.org/bots/api#botcommandscope + """ + type: base.String = fields.Field() + + @classmethod + def from_type(cls, type: str, **kwargs: typing.Any): + if type == BotCommandScopeType.DEFAULT: + return BotCommandScopeDefault(type=type, **kwargs) + if type == BotCommandScopeType.ALL_PRIVATE_CHATS: + return BotCommandScopeAllPrivateChats(type=type, **kwargs) + if type == BotCommandScopeType.ALL_GROUP_CHATS: + return BotCommandScopeAllGroupChats(type=type, **kwargs) + if type == BotCommandScopeType.ALL_CHAT_ADMINISTRATORS: + return BotCommandScopeAllChatAdministrators(type=type, **kwargs) + if type == BotCommandScopeType.CHAT: + return BotCommandScopeChat(type=type, **kwargs) + if type == BotCommandScopeType.CHAT_ADMINISTRATORS: + return BotCommandScopeChatAdministrators(type=type, **kwargs) + if type == BotCommandScopeType.CHAT_MEMBER: + return BotCommandScopeChatMember(type=type, **kwargs) + raise ValueError(f"Unknown BotCommandScope type {type!r}") + + +class BotCommandScopeDefault(BotCommandScope): + """ + Represents the default scope of bot commands. + Default commands are used if no commands with a narrower scope are + specified for the user. + """ + type = fields.Field(default=BotCommandScopeType.DEFAULT) + + +class BotCommandScopeAllPrivateChats(BotCommandScope): + """ + Represents the scope of bot commands, covering all private chats. + """ + type = fields.Field(default=BotCommandScopeType.ALL_PRIVATE_CHATS) + + +class BotCommandScopeAllGroupChats(BotCommandScope): + """ + Represents the scope of bot commands, covering all group and + supergroup chats. + """ + type = fields.Field(default=BotCommandScopeType.ALL_GROUP_CHATS) + + +class BotCommandScopeAllChatAdministrators(BotCommandScope): + """ + Represents the scope of bot commands, covering all group and + supergroup chat administrators. + """ + type = fields.Field(default=BotCommandScopeType.ALL_CHAT_ADMINISTRATORS) + + +class BotCommandScopeChat(BotCommandScope): + """ + Represents the scope of bot commands, covering a specific chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + + def __init__(self, chat_id: typing.Union[base.String, base.Integer], **kwargs): + super().__init__(chat_id=chat_id, **kwargs) + + +class BotCommandScopeChatAdministrators(BotCommandScopeChat): + """ + Represents the scope of bot commands, covering all administrators + of a specific group or supergroup chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT_ADMINISTRATORS) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + + +class BotCommandScopeChatMember(BotCommandScopeChat): + """ + Represents the scope of bot commands, covering a specific member of + a group or supergroup chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT_MEMBER) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + user_id: base.Integer = fields.Field() + + def __init__( + self, + chat_id: typing.Union[base.String, base.Integer], + user_id: base.Integer, + **kwargs, + ): + super().__init__(chat_id=chat_id, user_id=user_id, **kwargs) diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 5b3b315a..2cd19a0f 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -301,7 +301,7 @@ class Chat(base.TelegramObject): can_send_other_messages=can_send_other_messages, can_add_web_page_previews=can_add_web_page_previews) - async def promote(self, + async def promote(self, user_id: base.Integer, is_anonymous: typing.Optional[base.Boolean] = None, can_change_info: typing.Optional[base.Boolean] = None, @@ -321,36 +321,36 @@ class Chat(base.TelegramObject): :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - + :param is_anonymous: Pass True, if the administrator's presence in the chat is hidden :type is_anonymous: :obj:`typing.Optional[base.Boolean]` - + :param can_change_info: Pass True, if the administrator can change chat title, photo and other settings :type can_change_info: :obj:`typing.Optional[base.Boolean]` - + :param can_post_messages: Pass True, if the administrator can create channel posts, channels only :type can_post_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_edit_messages: Pass True, if the administrator can edit messages of other users, channels only :type can_edit_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_delete_messages: Pass True, if the administrator can delete messages of other users :type can_delete_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_invite_users: Pass True, if the administrator can invite new users to the chat :type can_invite_users: :obj:`typing.Optional[base.Boolean]` - + :param can_restrict_members: Pass True, if the administrator can restrict, ban or unban chat members :type can_restrict_members: :obj:`typing.Optional[base.Boolean]` - + :param can_pin_messages: Pass True, if the administrator can pin messages, supergroups only :type can_pin_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_promote_members: Pass True, if the administrator can add new administrators with a subset of his own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by him) :type can_promote_members: :obj:`typing.Optional[base.Boolean]` - + :return: Returns True on success. :rtype: :obj:`base.Boolean` """ @@ -484,16 +484,20 @@ class Chat(base.TelegramObject): """ return await self.bot.get_chat_administrators(self.id) - async def get_members_count(self) -> base.Integer: + async def get_member_count(self) -> base.Integer: """ Use this method to get the number of members in a chat. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount :return: Returns Int on success. :rtype: :obj:`base.Integer` """ - return await self.bot.get_chat_members_count(self.id) + return await self.bot.get_chat_member_count(self.id) + + async def get_members_count(self) -> base.Integer: + """Renamed to get_member_count.""" + return await self.get_member_count(self.id) async def get_member(self, user_id: base.Integer) -> ChatMember: """ diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index c48a91d0..58e4cb62 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,53 +1,11 @@ import datetime +from typing import Optional -from . import base -from . import fields +from . import base, fields from .user import User from ..utils import helper -class ChatMember(base.TelegramObject): - """ - This object contains information about one member of a chat. - - https://core.telegram.org/bots/api#chatmember - """ - user: User = fields.Field(base=User) - status: base.String = fields.Field() - custom_title: base.String = fields.Field() - is_anonymous: base.Boolean = fields.Field() - can_be_edited: base.Boolean = fields.Field() - can_manage_chat: base.Boolean = fields.Field() - can_post_messages: base.Boolean = fields.Field() - can_edit_messages: base.Boolean = fields.Field() - can_delete_messages: base.Boolean = fields.Field() - can_manage_voice_chats: base.Boolean = fields.Field() - can_restrict_members: base.Boolean = fields.Field() - can_promote_members: base.Boolean = fields.Field() - can_change_info: base.Boolean = fields.Field() - can_invite_users: base.Boolean = fields.Field() - can_pin_messages: base.Boolean = fields.Field() - is_member: base.Boolean = fields.Field() - can_send_messages: base.Boolean = fields.Field() - can_send_media_messages: base.Boolean = fields.Field() - can_send_polls: base.Boolean = fields.Field() - can_send_other_messages: base.Boolean = fields.Field() - can_add_web_page_previews: base.Boolean = fields.Field() - until_date: datetime.datetime = fields.DateTimeField() - - def is_chat_creator(self) -> bool: - return ChatMemberStatus.is_chat_creator(self.status) - - def is_chat_admin(self) -> bool: - return ChatMemberStatus.is_chat_admin(self.status) - - def is_chat_member(self) -> bool: - return ChatMemberStatus.is_chat_member(self.status) - - def __int__(self) -> int: - return self.user.id - - class ChatMemberStatus(helper.Helper): """ Chat member status @@ -55,11 +13,13 @@ class ChatMemberStatus(helper.Helper): mode = helper.HelperMode.lowercase CREATOR = helper.Item() # creator + OWNER = CREATOR # creator ADMINISTRATOR = helper.Item() # administrator MEMBER = helper.Item() # member RESTRICTED = helper.Item() # restricted LEFT = helper.Item() # left KICKED = helper.Item() # kicked + BANNED = KICKED # kicked @classmethod def is_chat_creator(cls, role: str) -> bool: @@ -72,3 +32,141 @@ class ChatMemberStatus(helper.Helper): @classmethod def is_chat_member(cls, role: str) -> bool: return role in (cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR, cls.RESTRICTED) + + @classmethod + def get_class_by_status(cls, status: str) -> Optional["ChatMember"]: + return { + cls.OWNER: ChatMemberOwner, + cls.ADMINISTRATOR: ChatMemberAdministrator, + cls.MEMBER: ChatMemberMember, + cls.RESTRICTED: ChatMemberRestricted, + cls.LEFT: ChatMemberLeft, + cls.BANNED: ChatMemberBanned, + }.get(status) + + +class ChatMember(base.TelegramObject): + """ + This object contains information about one member of a chat. + Currently, the following 6 types of chat members are supported: + ChatMemberOwner + ChatMemberAdministrator + ChatMemberMember + ChatMemberRestricted + ChatMemberLeft + ChatMemberBanned + + https://core.telegram.org/bots/api#chatmember + """ + status: base.String = fields.Field() + user: User = fields.Field(base=User) + + def __int__(self) -> int: + return self.user.id + + @classmethod + def resolve(cls, **kwargs) -> "ChatMember": + status = kwargs.get("status") + mapping = { + ChatMemberStatus.OWNER: ChatMemberOwner, + ChatMemberStatus.ADMINISTRATOR: ChatMemberAdministrator, + ChatMemberStatus.MEMBER: ChatMemberMember, + ChatMemberStatus.RESTRICTED: ChatMemberRestricted, + ChatMemberStatus.LEFT: ChatMemberLeft, + ChatMemberStatus.BANNED: ChatMemberBanned, + } + class_ = mapping.get(status) + if class_ is None: + raise ValueError(f"Can't find `ChatMember` class for status `{status}`") + + return class_(**kwargs) + + +class ChatMemberOwner(ChatMember): + """ + Represents a chat member that owns the chat and has all + administrator privileges. + https://core.telegram.org/bots/api#chatmemberowner + """ + status: base.String = fields.Field(default=ChatMemberStatus.OWNER) + user: User = fields.Field(base=User) + custom_title: base.String = fields.Field() + is_anonymous: base.Boolean = fields.Field() + + +class ChatMemberAdministrator(ChatMember): + """ + Represents a chat member that has some additional privileges. + + https://core.telegram.org/bots/api#chatmemberadministrator + """ + status: base.String = fields.Field(default=ChatMemberStatus.ADMINISTRATOR) + user: User = fields.Field(base=User) + can_be_edited: base.Boolean = fields.Field() + custom_title: base.String = fields.Field() + is_anonymous: base.Boolean = fields.Field() + can_manage_chat: base.Boolean = fields.Field() + can_post_messages: base.Boolean = fields.Field() + can_edit_messages: base.Boolean = fields.Field() + can_delete_messages: base.Boolean = fields.Field() + can_manage_voice_chats: base.Boolean = fields.Field() + can_restrict_members: base.Boolean = fields.Field() + can_promote_members: base.Boolean = fields.Field() + can_change_info: base.Boolean = fields.Field() + can_invite_users: base.Boolean = fields.Field() + can_pin_messages: base.Boolean = fields.Field() + + +class ChatMemberMember(ChatMember): + """ + Represents a chat member that has no additional privileges or + restrictions. + + https://core.telegram.org/bots/api#chatmembermember + """ + status: base.String = fields.Field(default=ChatMemberStatus.MEMBER) + user: User = fields.Field(base=User) + + +class ChatMemberRestricted(ChatMember): + """ + Represents a chat member that is under certain restrictions in the + chat. Supergroups only. + + https://core.telegram.org/bots/api#chatmemberrestricted + """ + status: base.String = fields.Field(default=ChatMemberStatus.RESTRICTED) + user: User = fields.Field(base=User) + is_member: base.Boolean = fields.Field() + can_change_info: base.Boolean = fields.Field() + can_invite_users: base.Boolean = fields.Field() + can_pin_messages: base.Boolean = fields.Field() + can_send_messages: base.Boolean = fields.Field() + can_send_media_messages: base.Boolean = fields.Field() + can_send_polls: base.Boolean = fields.Field() + can_send_other_messages: base.Boolean = fields.Field() + can_add_web_page_previews: base.Boolean = fields.Field() + until_date: datetime.datetime = fields.DateTimeField() + + +class ChatMemberLeft(ChatMember): + """ + Represents a chat member that isn't currently a member of the chat, + but may join it themselves. + + https://core.telegram.org/bots/api#chatmemberleft + """ + status: base.String = fields.Field(default=ChatMemberStatus.LEFT) + user: User = fields.Field(base=User) + + +class ChatMemberBanned(ChatMember): + """ + Represents a chat member that was banned in the chat and can't + return to the chat or view chat messages. + + https://core.telegram.org/bots/api#chatmemberbanned + """ + status: base.String = fields.Field(default=ChatMemberStatus.BANNED) + user: User = fields.Field(base=User) + until_date: datetime.datetime = fields.DateTimeField() diff --git a/aiogram/types/force_reply.py b/aiogram/types/force_reply.py index 97ec16c6..d6b4f19f 100644 --- a/aiogram/types/force_reply.py +++ b/aiogram/types/force_reply.py @@ -6,31 +6,28 @@ from . import fields class ForceReply(base.TelegramObject): """ - Upon receiving a message with this object, - Telegram clients will display a reply interface to the user - (act as if the user has selected the bot‘s message and tapped ’Reply'). - This can be extremely useful if you want to create user-friendly step-by-step + Upon receiving a message with this object, Telegram clients will + display a reply interface to the user (act as if the user has + selected the bot's message and tapped 'Reply'). This can be + extremely useful if you want to create user-friendly step-by-step interfaces without having to sacrifice privacy mode. - Example: A poll bot for groups runs in privacy mode - (only receives commands, replies to its messages and mentions). - There could be two ways to create a new poll - - The last option is definitely more attractive. - And if you use ForceReply in your bot‘s questions, it will receive the user’s answers even - if it only receives replies, commands and mentions — without any extra work for the user. - https://core.telegram.org/bots/api#forcereply """ force_reply: base.Boolean = fields.Field(default=True) + input_field_placeholder: base.String = fields.Field() selective: base.Boolean = fields.Field() @classmethod - def create(cls, selective: typing.Optional[base.Boolean] = None): + def create(cls, + input_field_placeholder: typing.Optional[base.String] = None, + selective: typing.Optional[base.Boolean] = None, + ) -> 'ForceReply': """ Create new force reply :param selective: + :param input_field_placeholder: :return: """ - return cls(selective=selective) + return cls(selective=selective, input_field_placeholder=input_field_placeholder) diff --git a/aiogram/types/reply_keyboard.py b/aiogram/types/reply_keyboard.py index ffe07ae1..e648e036 100644 --- a/aiogram/types/reply_keyboard.py +++ b/aiogram/types/reply_keyboard.py @@ -18,23 +18,32 @@ class KeyboardButtonPollType(base.TelegramObject): class ReplyKeyboardMarkup(base.TelegramObject): """ - This object represents a custom keyboard with reply options (see Introduction to bots for details and examples). + This object represents a custom keyboard with reply options + (see https://core.telegram.org/bots#keyboards to bots for details + and examples). https://core.telegram.org/bots/api#replykeyboardmarkup """ keyboard: 'typing.List[typing.List[KeyboardButton]]' = fields.ListOfLists(base='KeyboardButton', default=[]) resize_keyboard: base.Boolean = fields.Field() one_time_keyboard: base.Boolean = fields.Field() + input_field_placeholder: base.String = fields.Field() selective: base.Boolean = fields.Field() def __init__(self, keyboard: 'typing.List[typing.List[KeyboardButton]]' = None, resize_keyboard: base.Boolean = None, one_time_keyboard: base.Boolean = None, + input_field_placeholder: base.String = None, selective: base.Boolean = None, row_width: base.Integer = 3): - super(ReplyKeyboardMarkup, self).__init__(keyboard=keyboard, resize_keyboard=resize_keyboard, - one_time_keyboard=one_time_keyboard, selective=selective, - conf={'row_width': row_width}) + 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}, + ) @property def row_width(self): diff --git a/docs/source/index.rst b/docs/source/index.rst index 3631b150..cd4b99d0 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.2-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/tests/test_bot.py b/tests/test_bot.py index 224666ec..61abe962 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -427,7 +427,7 @@ async def test_get_chat_administrators(bot: Bot): """ getChatAdministrators method test """ from .types.dataset import CHAT, CHAT_MEMBER chat = types.Chat(**CHAT) - member = types.ChatMember(**CHAT_MEMBER) + member = types.ChatMember.resolve(**CHAT_MEMBER) async with FakeTelegram(message_data=[CHAT_MEMBER, CHAT_MEMBER]): result = await bot.get_chat_administrators(chat_id=chat.id) @@ -435,14 +435,14 @@ async def test_get_chat_administrators(bot: Bot): assert len(result) == 2 -async def test_get_chat_members_count(bot: Bot): +async def test_get_chat_member_count(bot: Bot): """ getChatMembersCount method test """ from .types.dataset import CHAT chat = types.Chat(**CHAT) count = 5 async with FakeTelegram(message_data=count): - result = await bot.get_chat_members_count(chat_id=chat.id) + result = await bot.get_chat_member_count(chat_id=chat.id) assert result == count @@ -450,7 +450,7 @@ async def test_get_chat_member(bot: Bot): """ getChatMember method test """ from .types.dataset import CHAT, CHAT_MEMBER chat = types.Chat(**CHAT) - member = types.ChatMember(**CHAT_MEMBER) + member = types.ChatMember.resolve(**CHAT_MEMBER) async with FakeTelegram(message_data=CHAT_MEMBER): result = await bot.get_chat_member(chat_id=chat.id, user_id=member.user.id) diff --git a/tests/types/test_chat_member.py b/tests/types/test_chat_member.py index 2cea44ce..2fe3e677 100644 --- a/tests/types/test_chat_member.py +++ b/tests/types/test_chat_member.py @@ -1,7 +1,7 @@ from aiogram import types from .dataset import CHAT_MEMBER -chat_member = types.ChatMember(**CHAT_MEMBER) +chat_member = types.ChatMember.resolve(**CHAT_MEMBER) def test_export(): From af031431f5c269b3febd8a003ef64b3b6eab91a6 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 5 Jul 2021 02:42:51 +0300 Subject: [PATCH 22/24] Fixed BotCommandScopeType helper mode --- aiogram/types/bot_command_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/types/bot_command_scope.py b/aiogram/types/bot_command_scope.py index e3091a7e..cb9bc78e 100644 --- a/aiogram/types/bot_command_scope.py +++ b/aiogram/types/bot_command_scope.py @@ -5,7 +5,7 @@ from ..utils import helper class BotCommandScopeType(helper.Helper): - mode = helper.HelperMode.lowercase + mode = helper.HelperMode.snake_case DEFAULT = helper.Item() # default ALL_PRIVATE_CHATS = helper.Item() # all_private_chats From 64a7a781dacf179fff45900a34963a54dddbd50c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 5 Jul 2021 02:43:32 +0300 Subject: [PATCH 23/24] Bump version --- aiogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 2d852a7e..e04c6870 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.14' +__version__ = '2.14.1' __api_version__ = '5.3' From 2e207c636cdc59e762c90c0f5a3ee02f8583f4b6 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Tue, 6 Jul 2021 01:10:51 +0300 Subject: [PATCH 24/24] fix fault on `reset_state` in memory storage (#619) --- aiogram/contrib/fsm_storage/memory.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/contrib/fsm_storage/memory.py b/aiogram/contrib/fsm_storage/memory.py index 8950aa8e..a5686a34 100644 --- a/aiogram/contrib/fsm_storage/memory.py +++ b/aiogram/contrib/fsm_storage/memory.py @@ -105,6 +105,7 @@ class MemoryStorage(BaseStorage): self.data[chat][user]['bucket'].update(bucket, **kwargs) def _cleanup(self, chat, user): + chat, user = self.resolve_address(chat=chat, user=user) if self.data[chat][user] == {'state': None, 'data': {}, 'bucket': {}}: del self.data[chat][user] if not self.data[chat]: