diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..3837c837 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,98 @@ +name: Bug report +description: Report issues affecting the framework or the documentation. +labels: + - bug +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: I am sure the error is coming from aiogram code + required: true + - label: I have searched in the issue tracker for similar bug reports, including closed ones + required: true + + - type: markdown + attributes: + value: | + ## Context + + Please provide as much information as possible. This will help us to reproduce the issue and fix it. + + - type: input + attributes: + label: Operating system + placeholder: e.g. Ubuntu 20.04.2 LTS + validations: + required: true + + - type: input + attributes: + label: Python version + placeholder: e.g. 3.10.1 + validations: + required: true + + - type: input + attributes: + label: aiogram version + placeholder: e.g. 2.21 or 3.0b3 + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: Please describe the behavior you are expecting. + placeholder: E.g. the bot should send a message with the text "Hello, world!". + validations: + required: true + + - type: textarea + attributes: + label: Current behavior + description: Please describe the behavior you are currently experiencing. + placeholder: E.g. the bot doesn't send any message. + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: Please describe the steps you took to reproduce the behavior. + placeholder: | + 1. step 1 + 2. step 2 + 3. ... + 4. you get it... + validations: + required: true + + - type: textarea + attributes: + label: Code example + description: Provide a [minimal, reproducible](https://stackoverflow.com/help/minimal-reproducible-example) and properly formatted example (if applicable). + placeholder: | + from aiogram import Bot, Dispatcher + ... + render: python3 + + - type: textarea + attributes: + label: Logs + description: Provide the complete traceback (if applicable) or other kind of logs. + placeholder: | + Traceback (most recent call last): + File "main.py", line 1, in + ... + SomeException: ... + render: sh + + - type: textarea + attributes: + label: Additional information + description: Please provide any additional information that may help us to reproduce the issue. + placeholder: | + E.g. this behavior is reproducible only in group chats. + + You can also attach additional screenshots, logs, or other files. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2a1994a5..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve - ---- - ---- -name: Bug report -about: Create a report to help us improve - ---- - -## Context - -Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. - -* Operating System: -* Python Version: -* aiogram version: -* aiohttp version: -* uvloop version (if installed): - -## Expected Behavior - -Please describe the behavior you are expecting - -## Current Behavior - -What is the current behavior? - -## Failure Information (for bugs) - -Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. - -### Steps to Reproduce - -Please provide detailed steps for reproducing the issue. - -1. step 1 -2. step 2 -3. you get it... - -### Failure Logs - -Please include any relevant log snippets or files here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..aee2a9d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Discuss anything related to the framework + url: https://github.com/aiogram/aiogram/discussions + about: Ask a question about aiogram or share your code snippets and ideas. + - name: Join our Telegram channel + url: https://t.me/aiogram_live + about: Get the latest updates about the framework. + - name: Join our Telegram chat + url: https://t.me/aiogram + about: Get help, ask questions, and discuss the framework in real-time. diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 00000000..7fbd1f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,44 @@ +name: Feature request +description: Report features you would like to see or improve in the framework. +labels: + - enhancement +body: + - type: textarea + attributes: + label: Problem + description: Is your feature request related to a specific problem? If not, please describe the general idea of your request. + placeholder: e.g. I want to send a photo to a user by url. + validations: + required: true + + - type: textarea + attributes: + label: Possible solution + description: Describe the solution you would like to see in the framework. + placeholder: e.g. Add a method to send a photo to a user by url. + validations: + required: true + + - type: textarea + attributes: + label: Alternatives + description: What other solutions do you have in mind? + placeholder: e.g. I'm sending a text message with photo url. + + - type: textarea + attributes: + label: Code example + description: A small code example that demonstrates the behavior you would like to see. + placeholder: | + await bot.send_photo(user_id, photo_url) + ... + render: python3 + + - type: textarea + attributes: + label: Additional information + description: Any additional information you would like to provide. + placeholder: | + E.g. this method should also cache images to speed up further sending. + + You can also attach additional pictures or other files. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 066b2d92..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1c33729f..cd082ad2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,52 @@ # Description -Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. + + +Type here... ## Type of change + -- [ ] Documentation (typos, code examples or any documentation update) -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update +- Breaking change (fix or feature that would cause existing functionality to not work as expected) +- Bug fix (non-breaking change that fixes an issue) +- New feature (non-breaking change that adds functionality) +- Documentation (typos, code examples or any documentation update) -# How Has This Been Tested? +# How has this been tested? -Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + -- [ ] Test A -- [ ] Test B +Type here... -**Test Configuration**: -* Operating System: -* Python version: +## Test Configuration +- Operating system: e.g. Ubuntu 20.04.2 LTS +-Python version: e.g. 3.10.1 # Checklist: + + - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have added tests that prove my fix is effective or that my feature works as expected - [ ] New and existing unit tests pass locally with my changes +- [ ] My changes generate no new warnings or errors +- [ ] My changes are compatible with minimum requirements of the project +- [ ] I have made corresponding changes to the documentation diff --git a/.github/workflows/label_pr.yaml b/.github/workflows/label_pr.yaml new file mode 100644 index 00000000..2b58136a --- /dev/null +++ b/.github/workflows/label_pr.yaml @@ -0,0 +1,17 @@ +name: Label new pull request + +on: + pull_request_target: + types: + - opened + branches: + - dev-2.x + +jobs: + put-label: + runs-on: ubuntu-latest + steps: + - name: Add 2.x label + uses: andymckay/labeler@master + with: + add-labels: 2.x diff --git a/README.md b/README.md index 6db8833f..fbc5c5bf 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-6.0-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-6.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 fd95af67..ee48c1be 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-6.1-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.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 85725de0..7ae9bfd2 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.21' -__api_version__ = '6.1' +__version__ = '2.22' +__api_version__ = '6.2' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 509e6c71..f287e32f 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -269,6 +269,7 @@ class Methods(Helper): SEND_STICKER = Item() # sendSticker GET_STICKER_SET = Item() # getStickerSet UPLOAD_STICKER_FILE = Item() # uploadStickerFile + GET_CUSTOM_EMOJI_STICKERS = Item() # getCustomEmojiStickers CREATE_NEW_STICKER_SET = Item() # createNewStickerSet ADD_STICKER_TO_SET = Item() # addStickerToSet SET_STICKER_POSITION_IN_SET = Item() # setStickerPositionInSet diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 8fd20949..0c236681 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -38,6 +38,7 @@ class BaseBot: validate_token: Optional[base.Boolean] = True, parse_mode: typing.Optional[base.String] = None, disable_web_page_preview: Optional[base.Boolean] = None, + protect_content: Optional[base.Boolean] = None, timeout: typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]] = None, server: TelegramAPIServer = TELEGRAM_PRODUCTION ): @@ -60,6 +61,9 @@ class BaseBot: :type parse_mode: :obj:`str` :param disable_web_page_preview: You can set default disable web page preview parameter :type disable_web_page_preview: :obj:`bool` + :param protect_content: Protects the contents of sent messages + from forwarding and saving + :type protect_content: :obj:`typing.Optional[base.Boolean]` :param timeout: Request timeout :type timeout: :obj:`typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]]` :param server: Telegram Bot API Server endpoint. @@ -111,6 +115,7 @@ class BaseBot: self.parse_mode = parse_mode self.disable_web_page_preview = disable_web_page_preview + self.protect_content = protect_content async def get_new_session(self) -> aiohttp.ClientSession: return aiohttp.ClientSession( @@ -361,5 +366,22 @@ class BaseBot: def disable_web_page_preview(self): self.disable_web_page_preview = None + @property + def protect_content(self): + return getattr(self, "_protect_content", None) + + @protect_content.setter + def protect_content(self, value): + if value is None: + setattr(self, "_protect_content", None) + return + if not isinstance(value, bool): + raise TypeError(f"Protect content must be bool, not {type(value)}") + setattr(self, "_protect_content", value) + + @protect_content.deleter + def protect_content(self): + self.protect_content = None + def check_auth_widget(self, data): return check_integrity(self.__token, data) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index c883409f..27c1ed63 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -335,6 +335,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload.setdefault('parse_mode', self.parse_mode) if self.disable_web_page_preview: payload.setdefault('disable_web_page_preview', self.disable_web_page_preview) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_MESSAGE, payload) return types.Message(**result) @@ -375,6 +377,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :rtype: :obj:`types.Message` """ payload = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.FORWARD_MESSAGE, payload) return types.Message(**result) @@ -457,6 +461,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.COPY_MESSAGE, payload) return types.MessageId(**result) @@ -525,6 +531,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=['photo']) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'photo', photo) @@ -615,6 +623,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=['audio', 'thumb']) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'audio', audio) @@ -705,6 +715,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=['document']) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'document', document) @@ -797,6 +809,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=['video', 'thumb']) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'video', video) @@ -892,6 +906,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=["animation", "thumb"]) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'animation', animation) @@ -972,6 +988,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals(), exclude=['voice']) if self.parse_mode and caption_entities is None: payload.setdefault('parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'voice', voice) @@ -1038,6 +1056,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals(), exclude=['video_note']) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'video_note', video_note) @@ -1101,6 +1121,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): media = prepare_arg(media) payload = generate_payload(**locals(), exclude=['files']) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_MEDIA_GROUP, payload, files) return [types.Message(**message) for message in result] @@ -1174,6 +1196,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_LOCATION, payload) return types.Message(**result) @@ -1352,6 +1376,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_VENUE, payload) return types.Message(**result) @@ -1415,6 +1441,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) result = await self.request(api.Methods.SEND_CONTACT, payload) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) return types.Message(**result) async def send_poll(self, @@ -1533,6 +1561,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) if self.parse_mode and explanation_entities is None: payload.setdefault('explanation_parse_mode', self.parse_mode) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_POLL, payload) return types.Message(**result) @@ -1592,6 +1622,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_DICE, payload) return types.Message(**result) @@ -2966,6 +2998,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals(), exclude=['sticker']) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) files = {} prepare_file(payload, files, 'sticker', sticker) @@ -3012,6 +3046,23 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.UPLOAD_STICKER_FILE, payload, files) return types.File(**result) + async def get_custom_emoji_stickers(self, custom_emoji_ids: typing.List[base.String]) -> typing.List[types.Sticker]: + """ + Use this method to get information about custom emoji stickers by their identifiers. + + + Source: https://core.telegram.org/bots/api#uploadstickerfile + + :param custom_emoji_ids: User identifier of sticker file owner + :type custom_emoji_ids: :obj:`typing.List[base.String]` + :return: Returns an Array of Sticker objects. + :rtype: :obj:`typing.List[types.Sticker]` + """ + payload = generate_payload(**locals()) + + result = await self.request(api.Methods.GET_CUSTOM_EMOJI_STICKERS, payload) + return [types.Sticker(**item) for item in result] + async def create_new_sticker_set(self, user_id: base.Integer, name: base.String, @@ -3021,6 +3072,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): tgs_sticker: base.InputFile = None, webm_sticker: base.InputFile = None, contains_masks: typing.Optional[base.Boolean] = None, + sticker_type: typing.Optional[base.String] = None, mask_position: typing.Optional[types.MaskPosition] = None) -> base.Boolean: """ Use this method to create a new sticker set owned by a user. @@ -3049,7 +3101,11 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type tgs_sticker: :obj:`base.InputFile` :param webm_sticker: WEBM video with the sticker, uploaded using multipart/form-data. See https://core.telegram.org/stickers#video-sticker-requirements for technical requirements - :type webm_sticker: :obj:`base.InputFile` + :type webm_sticker: :obj:`base.String` + :param sticker_type: Type of stickers in the set, pass “regular” or “mask”. + Custom emoji sticker sets can't be created via the Bot API at the moment. + By default, a regular sticker set is created. + :type sticker_type: :obj:`base.InputFile` :param emojis: One or more emoji corresponding to the sticker :type emojis: :obj:`base.String` :param contains_masks: Pass True, if a set of mask stickers should be created @@ -3061,6 +3117,12 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ mask_position = prepare_arg(mask_position) payload = generate_payload(**locals(), exclude=['png_sticker', 'tgs_sticker', 'webm_sticker']) + if contains_masks is not None: + warnings.warn( + message="The parameter `contains_masks` deprecated, use `sticker_type` instead.", + category=DeprecationWarning, + stacklevel=2 + ) files = {} prepare_file(payload, files, 'png_sticker', png_sticker) @@ -3398,6 +3460,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): reply_markup = prepare_arg(reply_markup) provider_data = prepare_arg(provider_data) payload_ = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_INVOICE, payload_) return types.Message(**result) @@ -3603,6 +3667,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) + if self.protect_content is not None: + payload.setdefault('protect_content', self.protect_content) result = await self.request(api.Methods.SEND_GAME, payload) return types.Message(**result) diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index 7a128f1c..6b7e61cd 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -213,5 +213,5 @@ class MongoStorage(BaseStorage): :return: list of tuples where first element is chat id and second is user id """ db = await self.get_db() - items = await db[STATE].find().to_list() + items = await db[STATE].find().to_list(length=None) return [(int(item['chat']), int(item['user'])) for item in items] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 373dafe5..e109eb6d 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -10,7 +10,7 @@ from babel.support import LazyProxy from aiogram import types from aiogram.dispatcher.filters.filters import BoundFilter, Filter -from aiogram.types import CallbackQuery, ChatType, InlineQuery, Message, Poll, ChatMemberUpdated +from aiogram.types import CallbackQuery, ChatType, InlineQuery, Message, Poll, ChatMemberUpdated, BotCommand ChatIDArgumentType = typing.Union[typing.Iterable[typing.Union[int, str]], str, int] @@ -34,7 +34,7 @@ class Command(Filter): By default this filter is registered for messages and edited messages handlers. """ - def __init__(self, commands: Union[Iterable, str], + def __init__(self, commands: Union[Iterable[Union[str, BotCommand]], str, BotCommand], prefixes: Union[Iterable, str] = '/', ignore_case: bool = True, ignore_mention: bool = False, @@ -66,8 +66,19 @@ class Command(Filter): @dp.message_handler(commands=['myCommand'], commands_ignore_caption=False, content_types=ContentType.ANY) @dp.message_handler(Command(['myCommand'], ignore_caption=False), content_types=[ContentType.TEXT, ContentType.DOCUMENT]) """ - if isinstance(commands, str): + if isinstance(commands, (str, BotCommand)): commands = (commands,) + elif isinstance(commands, Iterable): + if not all(isinstance(cmd, (str, BotCommand)) for cmd in commands): + raise ValueError( + "Command filter only supports str, BotCommand object or their Iterable" + ) + else: + raise ValueError( + "Command filter doesn't support {} as input. " + "It only supports str, BotCommand object or their Iterable".format(type(commands)) + ) + commands = [cmd.command if isinstance(cmd, BotCommand) else cmd for cmd in commands] self.commands = list(map(str.lower, commands)) if ignore_case else commands self.prefixes = prefixes diff --git a/aiogram/dispatcher/middlewares.py b/aiogram/dispatcher/middlewares.py index 5fa09830..cbe55220 100644 --- a/aiogram/dispatcher/middlewares.py +++ b/aiogram/dispatcher/middlewares.py @@ -110,6 +110,19 @@ class LifetimeControllerMiddleware(BaseMiddleware): # TODO: Rename class skip_patterns = None + _skip_actions = None + + @property + def skip_actions(self): + if self._skip_actions is None: + self._skip_actions = [] + if self.skip_patterns: + self._skip_actions.extend([ + f"pre_process_{item}", + f"process_{item}", + f"post_process_{item}", + ]) + return self._skip_actions async def pre_process(self, obj, data, *args): pass @@ -118,7 +131,7 @@ class LifetimeControllerMiddleware(BaseMiddleware): pass async def trigger(self, action, args): - if self.skip_patterns is not None and any(item in action for item in self.skip_patterns): + if action in self.skip_actions: return False obj, *args, data = args diff --git a/aiogram/dispatcher/storage.py b/aiogram/dispatcher/storage.py index 63ce25b2..dcc42ed8 100644 --- a/aiogram/dispatcher/storage.py +++ b/aiogram/dispatcher/storage.py @@ -271,7 +271,7 @@ class BaseStorage: :param user: :return: """ - await self.set_data(chat=chat, user=user, data={}) + await self.set_bucket(chat=chat, user=user, bucket={}) @staticmethod def resolve_state(value): diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 31ef88f1..32987899 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -458,6 +458,18 @@ class ProtectContentMixin: setattr(self, "protect_content", True) return self + @staticmethod + def _global_protect_content(): + """ + Detect global protect content value + + :return: + """ + from aiogram import Bot + bot = Bot.get_current() + if bot is not None: + return bot.protect_content + class ParseModeMixin: def as_html(self): @@ -536,6 +548,8 @@ class SendMessage(BaseResponse, ReplyToMixin, ParseModeMixin, parse_mode = self._global_parse_mode() if disable_web_page_preview is None: disable_web_page_preview = self._global_disable_web_page_preview() + if protect_content is None: + protect_content = self._global_protect_content() self.chat_id = chat_id self.text = text @@ -607,6 +621,9 @@ class ForwardMessage(BaseResponse, ReplyToMixin, DisableNotificationMixin, Prote from forwarding and saving :param message_id: Integer - Message identifier in the chat specified in from_chat_id """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.from_chat_id = from_chat_id self.message_id = message_id @@ -669,6 +686,9 @@ class SendPhoto(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCon - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.photo = photo self.caption = caption @@ -731,6 +751,9 @@ class SendAudio(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCon - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.audio = audio self.caption = caption @@ -793,6 +816,9 @@ class SendDocument(BaseResponse, ReplyToMixin, DisableNotificationMixin, Protect - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.document = document self.caption = caption @@ -856,6 +882,9 @@ class SendVideo(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCon - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.video = video self.duration = duration @@ -919,6 +948,9 @@ class SendVoice(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCon - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.voice = voice self.caption = caption @@ -977,6 +1009,9 @@ class SendVideoNote(BaseResponse, ReplyToMixin, DisableNotificationMixin, Protec - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.video_note = video_note self.duration = duration @@ -1037,6 +1072,8 @@ class SendMediaGroup(BaseResponse, ReplyToMixin, DisableNotificationMixin, Prote elif isinstance(media, list): # Convert list to MediaGroup media = types.MediaGroup(media) + if protect_content is None: + protect_content = self._global_protect_content() self.chat_id = chat_id self.media = media @@ -1117,6 +1154,9 @@ class SendLocation(BaseResponse, ReplyToMixin, DisableNotificationMixin, Protect - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.latitude = latitude self.longitude = longitude @@ -1177,6 +1217,9 @@ class SendVenue(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCon - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.latitude = latitude self.longitude = longitude @@ -1237,6 +1280,9 @@ class SendContact(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectC - Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.phone_number = phone_number self.first_name = first_name @@ -1845,6 +1891,9 @@ class SendSticker(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectC Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.sticker = sticker self.disable_notification = disable_notification @@ -2288,6 +2337,9 @@ class SendGame(BaseResponse, ReplyToMixin, DisableNotificationMixin, ProtectCont :param reply_markup: types.InlineKeyboardMarkup (Optional) - A JSON-serialized object for an inline keyboard. If empty, one ‘Play game_title’ button will be shown. If not empty, the first button must launch the game. """ + if protect_content is None: + protect_content = self._global_protect_content() + self.chat_id = chat_id self.game_short_name = game_short_name self.disable_notification = disable_notification diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 4377851a..506856cd 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -31,6 +31,7 @@ class Chat(base.TelegramObject): photo: ChatPhoto = fields.Field(base=ChatPhoto) bio: base.String = fields.Field() has_private_forwards: base.Boolean = fields.Field() + has_restricted_voice_and_video_messages: base.Boolean = fields.Field() join_to_send_messages: base.Boolean = fields.Field() join_by_request: base.Boolean = fields.Field() description: base.String = fields.Field() diff --git a/aiogram/types/chat_administrator_rights.py b/aiogram/types/chat_administrator_rights.py index 20be595b..4e909db8 100644 --- a/aiogram/types/chat_administrator_rights.py +++ b/aiogram/types/chat_administrator_rights.py @@ -19,3 +19,28 @@ class ChatAdministratorRights(base.TelegramObject): can_post_messages: base.Boolean = fields.Field() can_edit_messages: base.Boolean = fields.Field() can_pin_messages: base.Boolean = fields.Field() + + def __init__(self, + is_anonymous: base.Boolean = None, + can_manage_chat: base.Boolean = None, + can_delete_messages: base.Boolean = None, + can_manage_video_chats: base.Boolean = None, + can_restrict_members: base.Boolean = None, + can_promote_members: base.Boolean = None, + can_change_info: base.Boolean = None, + can_invite_users: base.Boolean = None, + can_post_messages: base.Boolean = None, + can_edit_messages: base.Boolean = None, + can_pin_messages: base.Boolean = None): + super(ChatAdministratorRights, self).__init__( + is_anonymous=is_anonymous, + can_manage_chat=can_manage_chat, + can_delete_messages=can_delete_messages, + can_manage_video_chats=can_manage_video_chats, + can_restrict_members=can_restrict_members, + can_promote_members=can_promote_members, + can_change_info=can_change_info, + can_invite_users=can_invite_users, + can_post_messages=can_post_messages, + can_edit_messages=can_edit_messages, + can_pin_messages=can_pin_messages) diff --git a/aiogram/types/message_entity.py b/aiogram/types/message_entity.py index 9788af05..9c910256 100644 --- a/aiogram/types/message_entity.py +++ b/aiogram/types/message_entity.py @@ -1,9 +1,9 @@ import sys -from ..utils import helper, markdown -from ..utils.deprecated import deprecated from . import base, fields from .user import User +from ..utils import helper, markdown +from ..utils.deprecated import deprecated class MessageEntity(base.TelegramObject): @@ -19,16 +19,18 @@ class MessageEntity(base.TelegramObject): url: base.String = fields.Field() user: User = fields.Field(base=User) language: base.String = fields.Field() + custom_emoji_id: base.String = fields.Field() def __init__( - self, - type: base.String, - offset: base.Integer, - length: base.Integer, - url: base.String = None, - user: User = None, - language: base.String = None, - **kwargs + self, + type: base.String, + offset: base.Integer, + length: base.Integer, + url: base.String = None, + user: User = None, + language: base.String = None, + custom_emoji_id: base.String = None, + **kwargs ): super().__init__( type=type, @@ -37,6 +39,7 @@ class MessageEntity(base.TelegramObject): url=url, user=user, language=language, + custom_emoji_id=custom_emoji_id, **kwargs ) @@ -94,6 +97,8 @@ class MessageEntity(base.TelegramObject): return method(entity_text, self.url) if self.type == MessageEntityType.TEXT_MENTION and self.user: return self.user.get_mention(entity_text, as_html=as_html) + if self.type == MessageEntityType.CUSTOM_EMOJI and self.user: + return entity_text return entity_text @@ -118,6 +123,7 @@ class MessageEntityType(helper.Helper): :key: PRE :key: TEXT_LINK :key: TEXT_MENTION + :key: CUSTOM_EMOJI """ mode = helper.HelperMode.snake_case @@ -138,3 +144,4 @@ class MessageEntityType(helper.Helper): PRE = helper.Item() # pre - monowidth block TEXT_LINK = helper.Item() # text_link - for clickable text URLs TEXT_MENTION = helper.Item() # text_mention - for users without usernames + CUSTOM_EMOJI = helper.Item() # custom_emoji diff --git a/aiogram/types/sticker.py b/aiogram/types/sticker.py index f1c0f527..b8e0c4ed 100644 --- a/aiogram/types/sticker.py +++ b/aiogram/types/sticker.py @@ -5,6 +5,7 @@ from .mask_position import MaskPosition from .photo_size import PhotoSize from .file import File + class Sticker(base.TelegramObject, mixins.Downloadable): """ This object represents a sticker. @@ -13,6 +14,7 @@ class Sticker(base.TelegramObject, mixins.Downloadable): """ file_id: base.String = fields.Field() file_unique_id: base.String = fields.Field() + type: base.String = fields.Field() width: base.Integer = fields.Field() height: base.Integer = fields.Field() is_animated: base.Boolean = fields.Field() @@ -22,6 +24,7 @@ class Sticker(base.TelegramObject, mixins.Downloadable): set_name: base.String = fields.Field() premium_animation: File = fields.Field(base=File) mask_position: MaskPosition = fields.Field(base=MaskPosition) + custom_emoji_id: base.String = fields.Field() file_size: base.Integer = fields.Field() async def set_position_in_set(self, position: base.Integer) -> base.Boolean: diff --git a/aiogram/types/sticker_set.py b/aiogram/types/sticker_set.py index dabae5db..809094c2 100644 --- a/aiogram/types/sticker_set.py +++ b/aiogram/types/sticker_set.py @@ -14,8 +14,9 @@ class StickerSet(base.TelegramObject): """ name: base.String = fields.Field() title: base.String = fields.Field() + sticker_type: base.String = fields.Field() is_animated: base.Boolean = fields.Field() is_video: base.Boolean = fields.Field() - contains_masks: base.Boolean = fields.Field() + contains_masks: base.Boolean = fields.Field() # Deprecated stickers: typing.List[Sticker] = fields.ListField(base=Sticker) thumb: PhotoSize = fields.Field(base=PhotoSize) diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index ae9af7d4..b7a8ae86 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -44,6 +44,8 @@ class TextDecoration(ABC): return self.link(value=text, link=f"tg://user?id={user.id}") if entity.type == "text_link": return self.link(value=text, link=cast(str, entity.url)) + if entity.type == "custom_emoji": + return self.custom_emoji(value=text, custom_emoji_id=entity.custom_emoji_id) return self.quote(text) @@ -143,6 +145,10 @@ class TextDecoration(ABC): def quote(self, value: str) -> str: # pragma: no cover pass + @abstractmethod + def custom_emoji(self, value: str, custom_emoji_id: str) -> str: # pragma: no cover + pass + class HtmlDecoration(TextDecoration): def link(self, value: str, link: str) -> str: @@ -175,6 +181,9 @@ class HtmlDecoration(TextDecoration): def quote(self, value: str) -> str: return html.escape(value, quote=False) + def custom_emoji(self, value: str, custom_emoji_id: str) -> str: + return f'{value}' + class MarkdownDecoration(TextDecoration): MARKDOWN_QUOTE_PATTERN: Pattern[str] = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])") @@ -209,6 +218,9 @@ class MarkdownDecoration(TextDecoration): def quote(self, value: str) -> str: return re.sub(pattern=self.MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=value) + def custom_emoji(self, value: str, custom_emoji_id: str) -> str: + return self.link(value=value, link=f"tg://emoji?id={custom_emoji_id}") + html_decoration = HtmlDecoration() markdown_decoration = MarkdownDecoration() diff --git a/docs/source/index.rst b/docs/source/index.rst index 84ce6405..5cdc2efa 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-6.1-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-6.2-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/test.html b/test.html deleted file mode 100644 index 61f09a01..00000000 --- a/test.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - Hello, world! - - -

Hello, world!

-
not inited
- - - - - \ No newline at end of file diff --git a/tests/test_dispatcher/test_filters/test_builtin.py b/tests/test_dispatcher/test_filters/test_builtin.py index 4f05cb22..35e6cb0a 100644 --- a/tests/test_dispatcher/test_filters/test_builtin.py +++ b/tests/test_dispatcher/test_filters/test_builtin.py @@ -1,4 +1,4 @@ -from typing import Set +from typing import Set, Union, Iterable from datetime import datetime import pytest @@ -6,9 +6,9 @@ import pytest from aiogram.dispatcher.filters.builtin import ( Text, extract_chat_ids, - ChatIDArgumentType, ForwardedMessageFilter, IDFilter, + ChatIDArgumentType, ForwardedMessageFilter, IDFilter, Command, ) -from aiogram.types import Message +from aiogram.types import Message, BotCommand from tests.types.dataset import MESSAGE, MESSAGE_FROM_CHANNEL @@ -108,3 +108,42 @@ class TestIDFilter: filter = IDFilter(chat_id=message_from_channel.chat.id) assert await filter.check(message_from_channel) + + +@pytest.mark.parametrize("command", [ + "/start", + "/start some args", +]) +@pytest.mark.parametrize("cmd_filter", [ + "start", + ("start",), + BotCommand(command="start", description="my desc"), + (BotCommand(command="start", description="bar"),), + (BotCommand(command="start", description="foo"), "help"), +]) +@pytest.mark.asyncio +async def test_commands_filter(command: str, cmd_filter: Union[Iterable[Union[str, BotCommand]], str, BotCommand]): + message_with_command = Message(**MESSAGE) + message_with_command.text = command + + start_filter = Command(commands=cmd_filter) + + assert await start_filter.check(message_with_command) + + +@pytest.mark.asyncio +async def test_commands_filter_not_checked(): + message_with_command = Message(**MESSAGE) + message_with_command.text = "/start" + + start_filter = Command(commands=["help", BotCommand("about", "my desc")]) + + assert not await start_filter.check(message_with_command) + + +def test_commands_filter_raises_error(): + with pytest.raises(ValueError): + start_filter = Command(commands=42) # noqa + with pytest.raises(ValueError): + start_filter = Command(commands=[42]) # noqa +