diff --git a/README.md b/README.md index c0a5bc26..155b2848 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-4.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.4-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/pip/stable.svg?style=flat-square)](http://aiogram.readthedocs.io/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 0377aad9..e4768f68 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-4.3-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.4-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 a1c2736b..b81ceedb 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -38,5 +38,5 @@ __all__ = [ 'utils' ] -__version__ = '2.2.1.dev1' -__api_version__ = '4.3' +__version__ = '2.3.dev1' +__api_version__ = '4.4' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 6c51b295..675626ac 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -147,7 +147,7 @@ class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api - List is updated to Bot API 4.3 + List is updated to Bot API 4.4 """ mode = HelperMode.lowerCamelCase @@ -182,6 +182,7 @@ class Methods(Helper): UNBAN_CHAT_MEMBER = Item() # unbanChatMember RESTRICT_CHAT_MEMBER = Item() # restrictChatMember PROMOTE_CHAT_MEMBER = Item() # promoteChatMember + SET_CHAT_PERMISSIONS = Item() # setChatPermissions EXPORT_CHAT_INVITE_LINK = Item() # exportChatInviteLink SET_CHAT_PHOTO = Item() # setChatPhoto DELETE_CHAT_PHOTO = Item() # deleteChatPhoto diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index e7fd5d6e..53e49997 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1,6 +1,7 @@ from __future__ import annotations import typing +import warnings from .base import BaseBot, api from .. import types @@ -345,7 +346,6 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): prepare_file(payload, files, 'audio', audio) prepare_attachment(payload, files, 'thumb', thumb) - result = await self.request(api.Methods.SEND_AUDIO, payload, files) return types.Message(**result) @@ -1016,6 +1016,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): async def restrict_chat_member(self, chat_id: typing.Union[base.Integer, base.String], user_id: base.Integer, + permissions: typing.Optional[types.ChatPermissions] = None, + # permissions argument need to be required after removing other `can_*` arguments until_date: typing.Union[base.Integer, None] = None, can_send_messages: typing.Union[base.Boolean, None] = None, can_send_media_messages: typing.Union[base.Boolean, None] = None, @@ -1032,6 +1034,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :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 permissions: New user permissions + :type permissions: :obj:`ChatPermissions` :param until_date: Date when restrictions will be lifted for the user, unix time :type until_date: :obj:`typing.Union[base.Integer, None]` :param can_send_messages: Pass True, if the user can send text messages, contacts, locations and venues @@ -1049,8 +1053,19 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :rtype: :obj:`base.Boolean` """ until_date = prepare_arg(until_date) + permissions = prepare_arg(permissions) payload = generate_payload(**locals()) + for permission in ['can_send_messages', + 'can_send_media_messages', + 'can_send_other_messages', + 'can_add_web_page_previews']: + if permission in payload: + warnings.warn(f"The method `restrict_chat_member` now takes the new user permissions " + f"in a single argument of the type ChatPermissions instead of " + f"passing regular argument {payload[permission]}", + DeprecationWarning, stacklevel=2) + result = await self.request(api.Methods.RESTRICT_CHAT_MEMBER, payload) return result @@ -1101,6 +1116,25 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.PROMOTE_CHAT_MEMBER, payload) return result + async def set_chat_permissions(self, chat_id: typing.Union[base.Integer, base.String], + permissions: types.ChatPermissions) -> base.Boolean: + """ + Use this method to set default chat permissions for all members. + The bot must be an administrator in the group or a supergroup for this to work and must have the + can_restrict_members admin rights. + + Returns True on success. + + :param chat_id: Unique identifier for the target chat or username of the target supergroup + :param permissions: New default chat permissions + :return: True on success. + """ + permissions = prepare_arg(permissions) + payload = generate_payload(**locals()) + + result = await self.request(api.Methods.SET_CHAT_PERMISSIONS) + return result + async def export_chat_invite_link(self, chat_id: typing.Union[base.Integer, base.String]) -> base.String: """ Use this method to generate a new invite link for a chat; any previously generated link is revoked. diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py new file mode 100644 index 00000000..9ec18090 --- /dev/null +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -0,0 +1,200 @@ +""" +This module has mongo storage for finite-state machine + based on `aiomongo AioMongoClient: + if isinstance(self._mongo, AioMongoClient): + return self._mongo + + uri = 'mongodb://' + + # set username + password + if self._username and self._password: + uri += f'{self._username}:{self._password}@' + + # set host and port (optional) + uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}' + + # define and return client + self._mongo = await aiomongo.create_client(uri) + return self._mongo + + async def get_db(self) -> Database: + """ + Get Mongo db + + This property is awaitable. + """ + if isinstance(self._db, Database): + return self._db + + mongo = await self.get_client() + self._db = mongo.get_database(self._db_name) + + if self._index: + await self.apply_index(self._db) + return self._db + + @staticmethod + async def apply_index(db): + for collection in COLLECTIONS: + await db[collection].create_index(keys=[('chat', 1), ('user', 1)], + name="chat_user_idx", unique=True, background=True) + + async def close(self): + if self._mongo: + self._mongo.close() + + async def wait_closed(self): + if self._mongo: + return await self._mongo.wait_closed() + return True + + 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() + + 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) + + async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[str] = None) -> Optional[str]: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[STATE].find_one(filter={'chat': chat, 'user': user}) + + return result.get('state') if result else default + + async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + 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) + + async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[DATA].find_one(filter={'chat': chat, 'user': user}) + + return result.get('data') if result else default or {} + + async def update_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + data: Dict = None, **kwargs): + if data is None: + data = {} + temp_data = await self.get_data(chat=chat, user=user, default={}) + temp_data.update(data, **kwargs) + await self.set_data(chat=chat, user=user, data=temp_data) + + def has_bucket(self): + return True + + async def get_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + default: Optional[dict] = None) -> Dict: + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + result = await db[BUCKET].find_one(filter={'chat': chat, 'user': user}) + return result.get('bucket') if result else default or {} + + async def set_bucket(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, + bucket: Dict = None): + chat, user = self.check_address(chat=chat, user=user) + db = await self.get_db() + + await db[BUCKET].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'bucket': bucket}}, upsert=True) + + async def update_bucket(self, *, chat: Union[str, int, None] = None, + user: Union[str, int, None] = None, + bucket: Dict = None, **kwargs): + if bucket is None: + bucket = {} + temp_bucket = await self.get_bucket(chat=chat, user=user) + temp_bucket.update(bucket, **kwargs) + await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) + + async def reset_all(self, full=True): + """ + Reset states in DB + + :param full: clean DB or clean only states + :return: + """ + db = await self.get_db() + + await db[STATE].drop() + + if full: + await db[DATA].drop() + await db[BUCKET].drop() + + async def get_states_list(self) -> List[Tuple[int, int]]: + """ + Get list of all stored chat's and user's + + :return: list of tuples where first element is chat id and second is user id + """ + db = await self.get_db() + result = [] + + items = await db[STATE].find().to_list() + for item in items: + result.append( + (int(item['chat']), int(item['user'])) + ) + + return result diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 8373f3d6..0bb10680 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -97,15 +97,13 @@ class I18nMiddleware(BaseMiddleware): if locale not in self.locales: if n is 1: return singular - else: - return plural + return plural translator = self.locales[locale] if plural is None: return translator.gettext(singular) - else: - return translator.ngettext(singular, plural, n) + return translator.ngettext(singular, plural, n) def lazy_gettext(self, singular, plural=None, n=1, locale=None, enable_cache=True) -> LazyProxy: """ diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 404cc8e1..a5bf5b9f 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -9,7 +9,7 @@ import aiohttp from aiohttp.helpers import sentinel from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \ - RegexpCommandsFilter, StateFilter, Text, IdFilter + RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -85,39 +85,64 @@ class Dispatcher(DataMixin, ContextInstanceMixin): filters_factory.bind(StateFilter, exclude_event_handlers=[ self.errors_handlers, - self.poll_handlers + self.poll_handlers, ]) filters_factory.bind(ContentTypeFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, ]), filters_factory.bind(Command, event_handlers=[ - self.message_handlers, self.edited_message_handlers + self.message_handlers, + self.edited_message_handlers ]) filters_factory.bind(Text, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - self.callback_query_handlers, self.poll_handlers, self.inline_query_handlers + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, + self.callback_query_handlers, + self.poll_handlers, + self.inline_query_handlers, ]) filters_factory.bind(HashTag, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, ]) filters_factory.bind(Regexp, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - self.callback_query_handlers, self.poll_handlers, self.inline_query_handlers + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, + self.callback_query_handlers, + self.poll_handlers, + self.inline_query_handlers, ]) filters_factory.bind(RegexpCommandsFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers + self.message_handlers, + self.edited_message_handlers, ]) filters_factory.bind(ExceptionsFilter, event_handlers=[ - self.errors_handlers + self.errors_handlers, ]) - filters_factory.bind(IdFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - self.callback_query_handlers, self.inline_query_handlers + filters_factory.bind(AdminFilter, event_handlers=[ + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, + self.callback_query_handlers, + self.inline_query_handlers, + ]) + filters_factory.bind(IDFilter, event_handlers=[ + self.message_handlers, + self.edited_message_handlers, + self.channel_post_handlers, + self.edited_channel_post_handlers, + self.callback_query_handlers, + self.inline_query_handlers, ]) def __del__(self): diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index eb4a5a52..277db03a 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,5 +1,5 @@ from .builtin import Command, CommandHelp, CommandPrivacy, CommandSettings, CommandStart, ContentTypeFilter, \ - ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, Text, IdFilter + ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter from .factory import FiltersFactory from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \ check_filters, get_filter_spec, get_filters_spec @@ -23,9 +23,10 @@ __all__ = [ 'Regexp', 'StateFilter', 'Text', - 'IdFilter', + 'IDFilter', + 'AdminFilter', 'get_filter_spec', 'get_filters_spec', 'execute_filter', - 'check_filters' + 'check_filters', ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 15cd73dd..e15b98de 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -9,7 +9,7 @@ from babel.support import LazyProxy from aiogram import types from aiogram.dispatcher.filters.filters import BoundFilter, Filter -from aiogram.types import CallbackQuery, Message, InlineQuery, Poll +from aiogram.types import CallbackQuery, Message, InlineQuery, Poll, ChatType class Command(Filter): @@ -84,9 +84,9 @@ class Command(Filter): if not ignore_mention and mention and (await message.bot.me).username.lower() != mention.lower(): return False - elif prefix not in prefixes: + if prefix not in prefixes: return False - elif (command.lower() if ignore_case else command) not in commands: + if (command.lower() if ignore_case else command) not in commands: return False return {'command': Command.CommandObj(command=command, prefix=prefix, mention=mention)} @@ -149,7 +149,7 @@ class CommandStart(Command): :param deep_link: string or compiled regular expression (by ``re.compile(...)``). """ - super(CommandStart, self).__init__(['start']) + super().__init__(['start']) self.deep_link = deep_link async def check(self, message: types.Message): @@ -159,7 +159,7 @@ class CommandStart(Command): :param message: :return: """ - check = await super(CommandStart, self).check(message) + check = await super().check(message) if check and self.deep_link is not None: if not isinstance(self.deep_link, re.Pattern): @@ -179,7 +179,7 @@ class CommandHelp(Command): """ def __init__(self): - super(CommandHelp, self).__init__(['help']) + super().__init__(['help']) class CommandSettings(Command): @@ -188,7 +188,7 @@ class CommandSettings(Command): """ def __init__(self): - super(CommandSettings, self).__init__(['settings']) + super().__init__(['settings']) class CommandPrivacy(Command): @@ -197,7 +197,7 @@ class CommandPrivacy(Command): """ def __init__(self): - super(CommandPrivacy, self).__init__(['privacy']) + super().__init__(['privacy']) class Text(Filter): @@ -205,19 +205,27 @@ class Text(Filter): Simple text filter """ + _default_params = ( + ('text', 'equals'), + ('text_contains', 'contains'), + ('text_startswith', 'startswith'), + ('text_endswith', 'endswith'), + ) + def __init__(self, - equals: Optional[Union[str, LazyProxy]] = None, - contains: Optional[Union[str, LazyProxy]] = None, - startswith: Optional[Union[str, LazyProxy]] = None, - endswith: Optional[Union[str, LazyProxy]] = None, + equals: Optional[Union[str, LazyProxy, Iterable[Union[str, LazyProxy]]]] = None, + contains: Optional[Union[str, LazyProxy, Iterable[Union[str, LazyProxy]]]] = None, + startswith: Optional[Union[str, LazyProxy, Iterable[Union[str, LazyProxy]]]] = None, + endswith: Optional[Union[str, LazyProxy, Iterable[Union[str, LazyProxy]]]] = None, ignore_case=False): """ Check text for one of pattern. Only one mode can be used in one filter. + In every pattern, a single string is treated as a list with 1 element. - :param equals: - :param contains: - :param startswith: - :param endswith: + :param equals: True if object's text in the list + :param contains: True if object's text contains all strings from the list + :param startswith: True if object's text starts with any of strings from the list + :param endswith: True if object's text ends with any of strings from the list :param ignore_case: case insensitive """ # Only one mode can be used. check it. @@ -232,6 +240,9 @@ class Text(Filter): elif check == 0: raise ValueError(f"No one mode is specified!") + equals, contains, endswith, startswith = map(lambda e: [e] if isinstance(e, str) or isinstance(e, LazyProxy) + else e, + (equals, contains, endswith, startswith)) self.equals = equals self.contains = contains self.endswith = endswith @@ -240,14 +251,9 @@ class Text(Filter): @classmethod def validate(cls, full_config: Dict[str, Any]): - if 'text' in full_config: - return {'equals': full_config.pop('text')} - elif 'text_contains' in full_config: - return {'contains': full_config.pop('text_contains')} - elif 'text_startswith' in full_config: - return {'startswith': full_config.pop('text_startswith')} - elif 'text_endswith' in full_config: - return {'endswith': full_config.pop('text_endswith')} + for param, key in cls._default_params: + if param in full_config: + return {key: full_config.pop(param)} async def check(self, obj: Union[Message, CallbackQuery, InlineQuery, Poll]): if isinstance(obj, Message): @@ -265,27 +271,26 @@ class Text(Filter): if self.ignore_case: text = text.lower() + _pre_process_func = lambda s: str(s).lower() + else: + _pre_process_func = str + # now check if self.equals is not None: - self.equals = str(self.equals) - if self.ignore_case: - self.equals = self.equals.lower() - return text == self.equals - elif self.contains is not None: - self.contains = str(self.contains) - if self.ignore_case: - self.contains = self.contains.lower() - return self.contains in text - elif self.startswith is not None: - self.startswith = str(self.startswith) - if self.ignore_case: - self.startswith = self.startswith.lower() - return text.startswith(self.startswith) - elif self.endswith is not None: - self.endswith = str(self.endswith) - if self.ignore_case: - self.endswith = self.endswith.lower() - return text.endswith(self.endswith) + equals = list(map(_pre_process_func, self.equals)) + return text in equals + + if self.contains is not None: + contains = list(map(_pre_process_func, self.contains)) + return all(map(text.__contains__, contains)) + + if self.startswith is not None: + startswith = list(map(_pre_process_func, self.startswith)) + return any(map(text.startswith, startswith)) + + if self.endswith is not None: + endswith = list(map(_pre_process_func, self.endswith)) + return any(map(text.endswith, endswith)) return False @@ -505,7 +510,7 @@ class ExceptionsFilter(BoundFilter): return False -class IdFilter(Filter): +class IDFilter(Filter): def __init__(self, user_id: Optional[Union[Iterable[Union[int, str]], str, int]] = None, @@ -560,9 +565,63 @@ class IdFilter(Filter): if self.user_id and self.chat_id: return user_id in self.user_id and chat_id in self.chat_id - elif self.user_id: + if self.user_id: return user_id in self.user_id - elif self.chat_id: + if self.chat_id: return chat_id in self.chat_id return False + + +class AdminFilter(Filter): + """ + Checks if user is admin in a chat. + If is_chat_admin is not set, the filter will check in the current chat (correct only for messages). + is_chat_admin is required for InlineQuery. + """ + + def __init__(self, is_chat_admin: Optional[Union[Iterable[Union[int, str]], str, int, bool]] = None): + self._check_current = False + self._chat_ids = None + + if is_chat_admin is False: + raise ValueError("is_chat_admin cannot be False") + + if is_chat_admin: + if isinstance(is_chat_admin, bool): + self._check_current = is_chat_admin + if isinstance(is_chat_admin, Iterable): + self._chat_ids = list(is_chat_admin) + else: + self._chat_ids = [is_chat_admin] + else: + self._check_current = True + + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + result = {} + + if "is_chat_admin" in full_config: + result["is_chat_admin"] = full_config.pop("is_chat_admin") + + return result + + async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]) -> bool: + user_id = obj.from_user.id + + if self._check_current: + if isinstance(obj, Message): + message = obj + elif isinstance(obj, CallbackQuery) and obj.message: + message = obj.message + else: + return False + if ChatType.is_private(message): # there is no admin in private chats + return False + chat_ids = [message.chat.id] + else: + chat_ids = self._chat_ids + + admins = [member.user.id for chat_id in chat_ids for member in await obj.bot.get_chat_administrators(chat_id)] + + return user_id in admins diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 89e3e792..13b188ff 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -70,4 +70,4 @@ class FiltersFactory: yield filter_ if full_config: - raise NameError('Invalid filter name(s): \'' + '\', '.join(full_config.keys()) + '\'') + raise NameError("Invalid filter name(s): '" + "', ".join(full_config.keys()) + "'") diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 46e44fc9..220ef96c 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -82,7 +82,7 @@ class FilterRecord: Filters record for factory """ - def __init__(self, callback: typing.Callable, + def __init__(self, callback: typing.Union[typing.Callable, 'AbstractFilter'], validator: typing.Optional[typing.Callable] = None, event_handlers: typing.Optional[typing.Iterable[Handler]] = None, exclude_event_handlers: typing.Optional[typing.Iterable[Handler]] = None): @@ -202,14 +202,14 @@ class BoundFilter(Filter): You need to implement ``__init__`` method with single argument related with key attribute and ``check`` method where you need to implement filter logic. """ - - """Unique name of the filter argument. You need to override this attribute.""" + key = None - """If :obj:`True` this filter will be added to the all of the registered handlers""" + """Unique name of the filter argument. You need to override this attribute.""" required = False - """Default value for configure required filters""" + """If :obj:`True` this filter will be added to the all of the registered handlers""" default = None - + """Default value for configure required filters""" + @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: """ diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index afe08e64..16937e1c 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -25,17 +25,17 @@ class State: @property def state(self): - if self._state is None: - return None - elif self._state == '*': + if self._state is None or self._state == '*': return self._state - elif self._group_name is None and self._group: + + if self._group_name is None and self._group: group = self._group.__full_group_name__ elif self._group_name: group = self._group_name else: group = '@' - return f"{group}:{self._state}" + + return f'{group}:{self._state}' def set_parent(self, group): if not issubclass(group, StatesGroup): @@ -73,7 +73,6 @@ class StatesGroupMeta(type): elif inspect.isclass(prop) and issubclass(prop, StatesGroup): childs.append(prop) prop._parent = cls - # continue cls._parent = None cls._childs = tuple(childs) @@ -83,13 +82,13 @@ class StatesGroupMeta(type): return cls @property - def __group_name__(cls): + def __group_name__(cls) -> str: return cls._group_name @property - def __full_group_name__(cls): + def __full_group_name__(cls) -> str: if cls._parent: - return cls._parent.__full_group_name__ + '.' + cls._group_name + return '.'.join((cls._parent.__full_group_name__, cls._group_name)) return cls._group_name @property @@ -97,7 +96,7 @@ class StatesGroupMeta(type): return cls._states @property - def childs(cls): + def childs(cls) -> tuple: return cls._childs @property @@ -130,9 +129,9 @@ class StatesGroupMeta(type): def __contains__(cls, item): if isinstance(item, str): return item in cls.all_states_names - elif isinstance(item, State): + if isinstance(item, State): return item in cls.all_states - elif isinstance(item, StatesGroup): + if isinstance(item, StatesGroup): return item in cls.all_childs return False diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index 17b715d1..cd5e9b50 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,7 +1,7 @@ import inspect from contextvars import ContextVar from dataclasses import dataclass -from typing import Optional, Iterable +from typing import Optional, Iterable, List ctx_data = ContextVar('ctx_handler_data') current_handler = ContextVar('current_handler') @@ -25,9 +25,8 @@ class CancelHandler(Exception): def _get_spec(func: callable): while hasattr(func, '__wrapped__'): # Try to resolve decorated callbacks func = func.__wrapped__ - spec = inspect.getfullargspec(func) - return spec, func + return spec def _check_spec(spec: inspect.FullArgSpec, kwargs: dict): @@ -42,11 +41,10 @@ class Handler: self.dispatcher = dispatcher self.once = once - self.handlers = [] + self.handlers: List[Handler.HandlerObj] = [] self.middleware_key = middleware_key def register(self, handler, filters=None, index=None): - from .filters import get_filters_spec """ Register callback @@ -56,7 +54,9 @@ class Handler: :param filters: list of filters :param index: you can reorder handlers """ - spec, handler = _get_spec(handler) + from .filters import get_filters_spec + + spec = _get_spec(handler) if filters and not isinstance(filters, (list, tuple, set)): filters = [filters] diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index bee635ae..135fe21e 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -523,7 +523,7 @@ class SendMessage(BaseResponse, ReplyToMixin, ParseModeMixin, DisableNotificatio 'disable_web_page_preview': self.disable_web_page_preview, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } def write(self, *text, sep=' '): @@ -642,7 +642,7 @@ class SendPhoto(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'caption': self.caption, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -704,7 +704,7 @@ class SendAudio(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'title': self.title, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -817,7 +817,7 @@ class SendVideo(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'caption': self.caption, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -871,7 +871,7 @@ class SendVoice(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'duration': self.duration, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -924,7 +924,7 @@ class SendVideoNote(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'length': self.length, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1050,7 +1050,7 @@ class SendLocation(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'longitude': self.longitude, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1109,7 +1109,7 @@ class SendVenue(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'foursquare_id': self.foursquare_id, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1160,7 +1160,7 @@ class SendContact(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'last_name': self.last_name, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1220,7 +1220,7 @@ class KickChatMember(BaseResponse): return { 'chat_id': self.chat_id, 'user_id': self.user_id, - 'until_date': prepare_arg(self.until_date) + 'until_date': prepare_arg(self.until_date), } @@ -1608,7 +1608,7 @@ class EditMessageText(BaseResponse, ParseModeMixin, DisableWebPagePreviewMixin): 'text': self.text, 'parse_mode': self.parse_mode, 'disable_web_page_preview': self.disable_web_page_preview, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1649,7 +1649,7 @@ class EditMessageCaption(BaseResponse): 'message_id': self.message_id, 'inline_message_id': self.inline_message_id, 'caption': self.caption, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1685,7 +1685,7 @@ class EditMessageReplyMarkup(BaseResponse): 'chat_id': self.chat_id, 'message_id': self.message_id, 'inline_message_id': self.inline_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1756,7 +1756,7 @@ class SendSticker(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'sticker': self.sticker, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } @@ -1848,7 +1848,7 @@ class AddStickerToSet(BaseResponse): 'name': self.name, 'png_sticker': self.png_sticker, 'emojis': self.emojis, - 'mask_position': prepare_arg(self.mask_position) + 'mask_position': prepare_arg(self.mask_position), } @@ -2177,5 +2177,5 @@ class SendGame(BaseResponse, ReplyToMixin, DisableNotificationMixin): 'game_short_name': self.game_short_name, 'disable_notification': self.disable_notification, 'reply_to_message_id': self.reply_to_message_id, - 'reply_markup': prepare_arg(self.reply_markup) + 'reply_markup': prepare_arg(self.reply_markup), } diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 5395e486..37dc4b3e 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -7,6 +7,7 @@ from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat, ChatActions, ChatType from .chat_member import ChatMember, ChatMemberStatus +from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto from .chosen_inline_result import ChosenInlineResult from .contact import Contact diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index cd34f1be..f5c521a5 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -5,6 +5,7 @@ import typing from . import base from . import fields +from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto from ..utils import helper from ..utils import markdown @@ -27,6 +28,7 @@ class Chat(base.TelegramObject): description: base.String = fields.Field() invite_link: base.String = fields.Field() pinned_message: 'Message' = fields.Field(base='Message') + permissions: ChatPermissions = fields.Field(base=ChatPermissions) sticker_set_name: base.String = fields.Field() can_set_sticker_set: base.Boolean = fields.Field() @@ -202,6 +204,7 @@ class Chat(base.TelegramObject): return await self.bot.unban_chat_member(self.id, user_id=user_id) async def restrict(self, user_id: base.Integer, + permissions: typing.Optional[ChatPermissions] = None, until_date: typing.Union[base.Integer, None] = None, can_send_messages: typing.Union[base.Boolean, None] = None, can_send_media_messages: typing.Union[base.Boolean, None] = None, @@ -216,6 +219,8 @@ class Chat(base.TelegramObject): :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` + :param permissions: New user permissions + :type permissions: :obj:`ChatPermissions` :param until_date: Date when restrictions will be lifted for the user, unix time. :type until_date: :obj:`typing.Union[base.Integer, None]` :param can_send_messages: Pass True, if the user can send text messages, contacts, locations and venues @@ -232,7 +237,9 @@ class Chat(base.TelegramObject): :return: Returns True on success. :rtype: :obj:`base.Boolean` """ - return await self.bot.restrict_chat_member(self.id, user_id=user_id, until_date=until_date, + return await self.bot.restrict_chat_member(self.id, user_id=user_id, + permissions=permissions, + until_date=until_date, can_send_messages=can_send_messages, can_send_media_messages=can_send_media_messages, can_send_other_messages=can_send_other_messages, diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 12789462..7e05a33f 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,5 +1,6 @@ import datetime import warnings +from typing import Optional from . import base from . import fields @@ -28,22 +29,17 @@ class ChatMember(base.TelegramObject): 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() - def is_admin(self): - warnings.warn('`is_admin` method deprecated due to updates in Bot API 4.2. ' - 'This method renamed to `is_chat_admin` and will be available until aiogram 2.3', - DeprecationWarning, stacklevel=2) - return self.is_chat_admin() + def is_chat_admin(self) -> bool: + return ChatMemberStatus.is_chat_admin(self.status) - def is_chat_admin(self): - return ChatMemberStatus.is_admin(self.status) + def is_chat_member(self) -> bool: + return ChatMemberStatus.is_chat_member(self.status) - def is_chat_member(self): - return ChatMemberStatus.is_member(self.status) - - def __int__(self): + def __int__(self) -> int: return self.user.id @@ -51,33 +47,19 @@ class ChatMemberStatus(helper.Helper): """ Chat member status """ - mode = helper.HelperMode.lowercase CREATOR = helper.Item() # creator ADMINISTRATOR = helper.Item() # administrator MEMBER = helper.Item() # member + RESTRICTED = helper.Item() # restricted LEFT = helper.Item() # left KICKED = helper.Item() # kicked @classmethod - def is_admin(cls, role): - warnings.warn('`is_admin` method deprecated due to updates in Bot API 4.2. ' - 'This method renamed to `is_chat_admin` and will be available until aiogram 2.3', - DeprecationWarning, stacklevel=2) - return cls.is_chat_admin(role) - - @classmethod - def is_member(cls, role): - warnings.warn('`is_member` method deprecated due to updates in Bot API 4.2. ' - 'This method renamed to `is_chat_member` and will be available until aiogram 2.3', - DeprecationWarning, stacklevel=2) - return cls.is_chat_member(role) - - @classmethod - def is_chat_admin(cls, role): + def is_chat_admin(cls, role: str) -> bool: return role in [cls.ADMINISTRATOR, cls.CREATOR] @classmethod - def is_chat_member(cls, role): - return role in [cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR] + def is_chat_member(cls, role: str) -> bool: + return role in [cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR, cls.RESTRICTED] diff --git a/aiogram/types/chat_permissions.py b/aiogram/types/chat_permissions.py new file mode 100644 index 00000000..9d44653e --- /dev/null +++ b/aiogram/types/chat_permissions.py @@ -0,0 +1,39 @@ +from . import base +from . import fields + + +class ChatPermissions(base.TelegramObject): + """ + Describes actions that a non-administrator user is allowed to take in a chat. + + https://core.telegram.org/bots/api#chatpermissions + """ + 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() + can_change_info: base.Boolean = fields.Field() + can_invite_users: base.Boolean = fields.Field() + can_pin_messages: base.Boolean = fields.Field() + + def __init__(self, + can_send_messages: base.Boolean = None, + can_send_media_messages: base.Boolean = None, + can_send_polls: base.Boolean = None, + can_send_other_messages: base.Boolean = None, + can_add_web_page_previews: base.Boolean = None, + can_change_info: base.Boolean = None, + can_invite_users: base.Boolean = None, + can_pin_messages: base.Boolean = None, + **kwargs): + super(ChatPermissions, self).__init__( + can_send_messages=can_send_messages, + can_send_media_messages=can_send_media_messages, + can_send_polls=can_send_polls, + can_send_other_messages=can_send_other_messages, + can_add_web_page_previews=can_add_web_page_previews, + can_change_info=can_change_info, + can_invite_users=can_invite_users, + can_pin_messages=can_pin_messages, + ) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 7637cf42..0097fe58 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -94,60 +94,60 @@ class Message(base.TelegramObject): def content_type(self): if self.text: return ContentType.TEXT - elif self.audio: + if self.audio: return ContentType.AUDIO - elif self.animation: + if self.animation: return ContentType.ANIMATION - elif self.document: + if self.document: return ContentType.DOCUMENT - elif self.game: + if self.game: return ContentType.GAME - elif self.photo: + if self.photo: return ContentType.PHOTO - elif self.sticker: + if self.sticker: return ContentType.STICKER - elif self.video: + if self.video: return ContentType.VIDEO - elif self.video_note: + if self.video_note: return ContentType.VIDEO_NOTE - elif self.voice: + if self.voice: return ContentType.VOICE - elif self.contact: + if self.contact: return ContentType.CONTACT - elif self.venue: + if self.venue: return ContentType.VENUE - elif self.location: + if self.location: return ContentType.LOCATION - elif self.new_chat_members: + if self.new_chat_members: return ContentType.NEW_CHAT_MEMBERS - elif self.left_chat_member: + if self.left_chat_member: return ContentType.LEFT_CHAT_MEMBER - elif self.invoice: + if self.invoice: return ContentType.INVOICE - elif self.successful_payment: + if self.successful_payment: return ContentType.SUCCESSFUL_PAYMENT - elif self.connected_website: + if self.connected_website: return ContentType.CONNECTED_WEBSITE - elif self.migrate_from_chat_id: + if self.migrate_from_chat_id: return ContentType.MIGRATE_FROM_CHAT_ID - elif self.migrate_to_chat_id: + if self.migrate_to_chat_id: return ContentType.MIGRATE_TO_CHAT_ID - elif self.pinned_message: + if self.pinned_message: return ContentType.PINNED_MESSAGE - elif self.new_chat_title: + if self.new_chat_title: return ContentType.NEW_CHAT_TITLE - elif self.new_chat_photo: + if self.new_chat_photo: return ContentType.NEW_CHAT_PHOTO - elif self.delete_chat_photo: + if self.delete_chat_photo: return ContentType.DELETE_CHAT_PHOTO - elif self.group_chat_created: + if self.group_chat_created: return ContentType.GROUP_CHAT_CREATED - elif self.passport_data: + if self.passport_data: return ContentType.PASSPORT_DATA - elif self.poll: + if self.poll: return ContentType.POLL - else: - return ContentType.UNKNOWN + + return ContentType.UNKNOWN def is_command(self): """ @@ -959,70 +959,6 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def send_animation(self, animation: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: - """ - Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). - - On success, the sent Message is returned. - Bots can currently send animation files of up to 50 MB in size, this limit may be changed in the future. - - Source https://core.telegram.org/bots/api#sendanimation - - :param animation: Animation to send. Pass a file_id as String to send an animation that exists - on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get an animation - from the Internet, or upload a new animation using multipart/form-data - :type animation: :obj:`typing.Union[base.InputFile, base.String]` - :param duration: Duration of sent animation in seconds - :type duration: :obj:`typing.Union[base.Integer, None]` - :param width: Animation width - :type width: :obj:`typing.Union[base.Integer, None]` - :param height: Animation height - :type height: :obj:`typing.Union[base.Integer, None]` - :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. - A thumbnail‘s width and height should not exceed 90. - :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` - :param caption: Animation caption (may also be used when resending animation by file_id), 0-1024 characters - :type caption: :obj:`typing.Union[base.String, None]` - :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, - fixed-width text or inline URLs in the media caption - :type parse_mode: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound - :type disable_notification: :obj:`typing.Union[base.Boolean, None]` - :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, - custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user - :type reply_markup: :obj:`typing.Union[typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, - types.ReplyKeyboardRemove, types.ForceReply], None]` - :param reply: fill 'reply_to_message_id' - :return: On success, the sent Message is returned - :rtype: :obj:`types.Message` - """ - warn_deprecated('"Message.send_animation" method will be removed in 2.3 version.\n' - 'Use "Message.reply_animation" instead.', - stacklevel=8) - - return await self.bot.send_animation(self.chat.id, - animation=animation, - duration=duration, - width=width, - height=height, - thumb=thumb, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) async def reply_animation(self, animation: typing.Union[base.InputFile, base.String], duration: typing.Union[base.Integer, None] = None, @@ -1323,55 +1259,6 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def send_venue(self, - latitude: base.Float, longitude: base.Float, - title: base.String, address: base.String, - foursquare_id: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: - """ - Use this method to send information about a venue. - - Source: https://core.telegram.org/bots/api#sendvenue - - :param latitude: Latitude of the venue - :type latitude: :obj:`base.Float` - :param longitude: Longitude of the venue - :type longitude: :obj:`base.Float` - :param title: Name of the venue - :type title: :obj:`base.String` - :param address: Address of the venue - :type address: :obj:`base.String` - :param foursquare_id: Foursquare identifier of the venue - :type foursquare_id: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. - :type disable_notification: :obj:`typing.Union[base.Boolean, None]` - :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, - custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :param reply: fill 'reply_to_message_id' - :return: On success, the sent Message is returned. - :rtype: :obj:`types.Message` - """ - warn_deprecated('"Message.send_venue" method will be removed in 2.3 version.\n' - 'Use "Message.reply_venue" instead.', - stacklevel=8) - - return await self.bot.send_venue(chat_id=self.chat.id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) - async def reply_venue(self, latitude: base.Float, longitude: base.Float, title: base.String, address: base.String, @@ -1417,46 +1304,6 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def send_contact(self, phone_number: base.String, - first_name: base.String, last_name: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup: typing.Union[InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, None] = None, - reply: base.Boolean = True) -> Message: - """ - Use this method to send phone contacts. - - Source: https://core.telegram.org/bots/api#sendcontact - - :param phone_number: Contact's phone number - :type phone_number: :obj:`base.String` - :param first_name: Contact's first name - :type first_name: :obj:`base.String` - :param last_name: Contact's last name - :type last_name: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. - :type disable_notification: :obj:`typing.Union[base.Boolean, None]` - :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, - custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :param reply: fill 'reply_to_message_id' - :return: On success, the sent Message is returned. - :rtype: :obj:`types.Message` - """ - warn_deprecated('"Message.send_contact" method will be removed in 2.3 version.\n' - 'Use "Message.reply_contact" instead.', - stacklevel=8) - - return await self.bot.send_contact(chat_id=self.chat.id, - phone_number=phone_number, - first_name=first_name, last_name=last_name, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) - async def reply_contact(self, phone_number: base.String, first_name: base.String, last_name: typing.Union[base.String, None] = None, disable_notification: typing.Union[base.Boolean, None] = None, diff --git a/aiogram/types/message_entity.py b/aiogram/types/message_entity.py index abb4f060..b9a9103e 100644 --- a/aiogram/types/message_entity.py +++ b/aiogram/types/message_entity.py @@ -49,30 +49,24 @@ class MessageEntity(base.TelegramObject): entity_text = self.get_text(text) if self.type == MessageEntityType.BOLD: - if as_html: - return markdown.hbold(entity_text) - return markdown.bold(entity_text) - elif self.type == MessageEntityType.ITALIC: - if as_html: - return markdown.hitalic(entity_text) - return markdown.italic(entity_text) - elif self.type == MessageEntityType.PRE: - if as_html: - return markdown.hpre(entity_text) - return markdown.pre(entity_text) - elif self.type == MessageEntityType.CODE: - if as_html: - return markdown.hcode(entity_text) - return markdown.code(entity_text) - elif self.type == MessageEntityType.URL: - if as_html: - return markdown.hlink(entity_text, entity_text) - return markdown.link(entity_text, entity_text) - elif self.type == MessageEntityType.TEXT_LINK: - if as_html: - return markdown.hlink(entity_text, self.url) - return markdown.link(entity_text, self.url) - elif self.type == MessageEntityType.TEXT_MENTION and self.user: + method = markdown.hbold if as_html else markdown.bold + return method(entity_text) + if self.type == MessageEntityType.ITALIC: + method = markdown.hitalic if as_html else markdown.italic + return method(entity_text) + if self.type == MessageEntityType.PRE: + method = markdown.hpre if as_html else markdown.pre + return method(entity_text) + if self.type == MessageEntityType.CODE: + method = markdown.hcode if as_html else markdown.code + return method(entity_text) + if self.type == MessageEntityType.URL: + method = markdown.hlink if as_html else markdown.link + return method(entity_text, entity_text) + if self.type == MessageEntityType.TEXT_LINK: + method = markdown.hlink if as_html else markdown.link + return method(entity_text, self.url) + if self.type == MessageEntityType.TEXT_MENTION and self.user: return self.user.get_mention(entity_text) return entity_text diff --git a/aiogram/types/sticker.py b/aiogram/types/sticker.py index b2fd7ef6..8da1e9eb 100644 --- a/aiogram/types/sticker.py +++ b/aiogram/types/sticker.py @@ -14,6 +14,7 @@ class Sticker(base.TelegramObject, mixins.Downloadable): file_id: base.String = fields.Field() width: base.Integer = fields.Field() height: base.Integer = fields.Field() + is_animated: base.Boolean = fields.Field() thumb: PhotoSize = fields.Field(base=PhotoSize) emoji: base.String = fields.Field() set_name: base.String = fields.Field() diff --git a/aiogram/types/sticker_set.py b/aiogram/types/sticker_set.py index 9d302bae..cb30abe6 100644 --- a/aiogram/types/sticker_set.py +++ b/aiogram/types/sticker_set.py @@ -13,5 +13,6 @@ class StickerSet(base.TelegramObject): """ name: base.String = fields.Field() title: base.String = fields.Field() + is_animated: base.Boolean = fields.Field() contains_masks: base.Boolean = fields.Field() stickers: typing.List[Sticker] = fields.ListField(base=Sticker) diff --git a/aiogram/types/user.py b/aiogram/types/user.py index 441c275f..27ee27e0 100644 --- a/aiogram/types/user.py +++ b/aiogram/types/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + import babel from . import base @@ -45,7 +47,7 @@ class User(base.TelegramObject): return self.full_name @property - def locale(self) -> babel.core.Locale or None: + def locale(self) -> Optional[babel.core.Locale]: """ Get user's locale diff --git a/aiogram/utils/callback_data.py b/aiogram/utils/callback_data.py index 916d08c4..b0162a7e 100644 --- a/aiogram/utils/callback_data.py +++ b/aiogram/utils/callback_data.py @@ -28,13 +28,13 @@ class CallbackData: def __init__(self, prefix, *parts, sep=':'): if not isinstance(prefix, str): - raise TypeError(f"Prefix must be instance of str not {type(prefix).__name__}") - elif not prefix: - raise ValueError('Prefix can\'t be empty') - elif sep in prefix: - raise ValueError(f"Separator '{sep}' can't be used in prefix") - elif not parts: - raise TypeError('Parts is not passed!') + raise TypeError(f'Prefix must be instance of str not {type(prefix).__name__}') + if not prefix: + raise ValueError("Prefix can't be empty") + if sep in prefix: + raise ValueError(f"Separator {sep!r} can't be used in prefix") + if not parts: + raise TypeError('Parts were not passed!') self.prefix = prefix self.sep = sep @@ -59,20 +59,20 @@ class CallbackData: if args: value = args.pop(0) else: - raise ValueError(f"Value for '{part}' is not passed!") + raise ValueError(f'Value for {part!r} was not passed!') if value is not None and not isinstance(value, str): value = str(value) if not value: - raise ValueError(f"Value for part {part} can't be empty!'") - elif self.sep in value: - raise ValueError(f"Symbol defined as separator can't be used in values of parts") + raise ValueError(f"Value for part {part!r} can't be empty!'") + if self.sep in value: + raise ValueError(f"Symbol {self.sep!r} is defined as the separator and can't be used in parts' values") data.append(value) if args or kwargs: - raise TypeError('Too many arguments is passed!') + raise TypeError('Too many arguments were passed!') callback_data = self.sep.join(data) if len(callback_data) > 64: @@ -106,30 +106,31 @@ class CallbackData: """ for key in config.keys(): if key not in self._part_names: - raise ValueError(f"Invalid field name '{key}'") + raise ValueError(f'Invalid field name {key!r}') return CallbackDataFilter(self, config) class CallbackDataFilter(Filter): + def __init__(self, factory: CallbackData, config: typing.Dict[str, str]): self.config = config self.factory = factory @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]): - raise ValueError('That filter can\'t be used in filters factory!') + raise ValueError("That filter can't be used in filters factory!") async def check(self, query: types.CallbackQuery): try: data = self.factory.parse(query.data) except ValueError: return False - else: - for key, value in self.config.items(): - if isinstance(value, (list, tuple, set)): - if data.get(key) not in value: - return False - else: - if value != data.get(key): - return False - return {'callback_data': data} + + for key, value in self.config.items(): + if isinstance(value, (list, tuple, set, frozenset)): + if data.get(key) not in value: + return False + else: + if data.get(key) != value: + return False + return {'callback_data': data} diff --git a/aiogram/utils/deprecated.py b/aiogram/utils/deprecated.py index 1ea2561d..8a527420 100644 --- a/aiogram/utils/deprecated.py +++ b/aiogram/utils/deprecated.py @@ -1,10 +1,7 @@ -""" -Source: https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically -""" - import functools import inspect import warnings +import asyncio def deprecated(reason): @@ -12,6 +9,8 @@ def deprecated(reason): This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted when the function is used. + + Source: https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically """ if isinstance(reason, str): @@ -41,7 +40,7 @@ def deprecated(reason): return decorator - elif inspect.isclass(reason) or inspect.isfunction(reason): + if inspect.isclass(reason) or inspect.isfunction(reason): # The @deprecated is used without any 'reason'. # @@ -65,11 +64,71 @@ def deprecated(reason): return wrapper1 - else: - raise TypeError(repr(type(reason))) + raise TypeError(repr(type(reason))) def warn_deprecated(message, warning=DeprecationWarning, stacklevel=2): warnings.simplefilter('always', warning) warnings.warn(message, category=warning, stacklevel=stacklevel) warnings.simplefilter('default', warning) + + +def renamed_argument(old_name: str, new_name: str, until_version: str, stacklevel: int = 3): + """ + A meta-decorator to mark an argument as deprecated. + + .. code-block:: python3 + + @renamed_argument("chat", "chat_id", "3.0") # stacklevel=3 by default + @renamed_argument("user", "user_id", "3.0", stacklevel=4) + def some_function(user_id, chat_id=None): + print(f"user_id={user_id}, chat_id={chat_id}") + + some_function(user=123) # prints 'user_id=123, chat_id=None' with warning + some_function(123) # prints 'user_id=123, chat_id=None' without warning + some_function(user_id=123) # prints 'user_id=123, chat_id=None' without warning + + + :param old_name: + :param new_name: + :param until_version: the version in which the argument is scheduled to be removed + :param stacklevel: leave it to default if it's the first decorator used. + Increment with any new decorator used. + :return: decorator + """ + + def decorator(func): + if asyncio.iscoroutinefunction(func): + @functools.wraps(func) + async def wrapped(*args, **kwargs): + if old_name in kwargs: + warn_deprecated(f"In coroutine '{func.__name__}' argument '{old_name}' " + f"is renamed to '{new_name}' " + f"and will be removed in aiogram {until_version}", + stacklevel=stacklevel) + kwargs.update( + { + new_name: kwargs[old_name], + } + ) + kwargs.pop(old_name) + await func(*args, **kwargs) + else: + @functools.wraps(func) + def wrapped(*args, **kwargs): + if old_name in kwargs: + warn_deprecated(f"In function `{func.__name__}` argument `{old_name}` " + f"is renamed to `{new_name}` " + f"and will be removed in aiogram {until_version}", + stacklevel=stacklevel) + kwargs.update( + { + new_name: kwargs[old_name], + } + ) + kwargs.pop(old_name) + func(*args, **kwargs) + + return wrapped + + return decorator diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index 65594371..33f80684 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -15,7 +15,7 @@ from ..dispatcher.webhook import BOT_DISPATCHER_KEY, DEFAULT_ROUTE_NAME, Webhook APP_EXECUTOR_KEY = 'APP_EXECUTOR' -def _setup_callbacks(executor, on_startup=None, on_shutdown=None): +def _setup_callbacks(executor: 'Executor', on_startup=None, on_shutdown=None): if on_startup is not None: executor.on_startup(on_startup) if on_shutdown is not None: @@ -23,7 +23,7 @@ def _setup_callbacks(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, fast=True): + on_startup=None, on_shutdown=None, timeout=20, relax=0.1, fast=True): """ Start bot in long-polling mode @@ -38,7 +38,7 @@ def start_polling(dispatcher, *, loop=None, skip_updates=False, reset_webhook=Tr 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, fast=fast) + executor.start_polling(reset_webhook=reset_webhook, timeout=timeout, relax=relax, fast=fast) def set_webhook(dispatcher: Dispatcher, webhook_path: str, *, loop: Optional[asyncio.AbstractEventLoop] = None, @@ -291,7 +291,7 @@ 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, fast=True): + def start_polling(self, reset_webhook=None, timeout=20, relax=0.1, fast=True): """ Start bot in long-polling mode @@ -303,7 +303,8 @@ class Executor: try: loop.run_until_complete(self._startup_polling()) - loop.create_task(self.dispatcher.start_polling(reset_webhook=reset_webhook, timeout=timeout, fast=fast)) + loop.create_task(self.dispatcher.start_polling(reset_webhook=reset_webhook, timeout=timeout, + relax=relax, fast=fast)) loop.run_forever() except (KeyboardInterrupt, SystemExit): # loop.stop() @@ -339,7 +340,7 @@ class Executor: async def _skip_updates(self): await self.dispatcher.reset_webhook(True) await self.dispatcher.skip_updates() - log.warning(f"Updates are skipped successfully.") + log.warning(f'Updates were skipped successfully.') async def _welcome(self): user = await self.dispatcher.bot.me diff --git a/aiogram/utils/helper.py b/aiogram/utils/helper.py index eeabca7c..443a2ffe 100644 --- a/aiogram/utils/helper.py +++ b/aiogram/utils/helper.py @@ -120,15 +120,15 @@ class HelperMode(Helper): """ if mode == cls.SCREAMING_SNAKE_CASE: return cls._screaming_snake_case(text) - elif mode == cls.snake_case: + if mode == cls.snake_case: return cls._snake_case(text) - elif mode == cls.lowercase: + if mode == cls.lowercase: return cls._snake_case(text).replace('_', '') - elif mode == cls.lowerCamelCase: + if mode == cls.lowerCamelCase: return cls._camel_case(text) - elif mode == cls.CamelCase: + if mode == cls.CamelCase: return cls._camel_case(text, True) - elif callable(mode): + if callable(mode): return mode(text) return text diff --git a/aiogram/utils/mixins.py b/aiogram/utils/mixins.py index 776479bd..e6857263 100644 --- a/aiogram/utils/mixins.py +++ b/aiogram/utils/mixins.py @@ -31,7 +31,7 @@ T = TypeVar('T') class ContextInstanceMixin: def __init_subclass__(cls, **kwargs): - cls.__context_instance = contextvars.ContextVar('instance_' + cls.__name__) + cls.__context_instance = contextvars.ContextVar(f'instance_{cls.__name__}') return cls @classmethod @@ -43,5 +43,5 @@ class ContextInstanceMixin: @classmethod def set_current(cls: Type[T], value: T): if not isinstance(value, cls): - raise TypeError(f"Value should be instance of '{cls.__name__}' not '{type(value).__name__}'") + raise TypeError(f'Value should be instance of {cls.__name__!r} not {type(value).__name__!r}') cls.__context_instance.set(value) diff --git a/aiogram/utils/payload.py b/aiogram/utils/payload.py index 45643553..0c5e8ae9 100644 --- a/aiogram/utils/payload.py +++ b/aiogram/utils/payload.py @@ -52,14 +52,14 @@ def prepare_arg(value): """ if value is None: return value - elif isinstance(value, (list, dict)) or hasattr(value, 'to_python'): + if isinstance(value, (list, dict)) or hasattr(value, 'to_python'): return json.dumps(_normalize(value)) - elif isinstance(value, datetime.timedelta): + if isinstance(value, datetime.timedelta): now = datetime.datetime.now() return int((now + value).timestamp()) - elif isinstance(value, datetime.datetime): + if isinstance(value, datetime.datetime): return round(value.timestamp()) - elif isinstance(value, LazyProxy): + if isinstance(value, LazyProxy): return str(value) return value diff --git a/dev_requirements.txt b/dev_requirements.txt index 79adc949..06bc3e9c 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -15,3 +15,4 @@ sphinx-rtd-theme>=0.4.3 sphinxcontrib-programoutput>=0.14 aiohttp-socks>=0.2.2 rethinkdb>=2.4.1 +coverage==4.5.3 diff --git a/docs/source/dispatcher/filters.rst b/docs/source/dispatcher/filters.rst index af03e163..059a4f06 100644 --- a/docs/source/dispatcher/filters.rst +++ b/docs/source/dispatcher/filters.rst @@ -111,10 +111,18 @@ ExceptionsFilter :show-inheritance: -IdFilter +IDFilter ---------------- -.. autoclass:: aiogram.dispatcher.filters.builtin.IdFilter +.. autoclass:: aiogram.dispatcher.filters.builtin.IDFilter + :members: + :show-inheritance: + + +AdminFilter +---------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.AdminFilter :members: :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst index 89cdbf79..543c793a 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-4.3-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.4-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/docs/source/utils/deprecated.rst b/docs/source/utils/deprecated.rst index 0a7b4089..7f2f07cc 100644 --- a/docs/source/utils/deprecated.rst +++ b/docs/source/utils/deprecated.rst @@ -1,4 +1,5 @@ ========== Deprecated ========== -Coming soon... +.. automodule:: aiogram.utils.deprecated + :members: diff --git a/examples/admin_filter_example.py b/examples/admin_filter_example.py new file mode 100644 index 00000000..ec8746bb --- /dev/null +++ b/examples/admin_filter_example.py @@ -0,0 +1,33 @@ +import logging + +from aiogram import Bot, Dispatcher, types, executor + +API_TOKEN = 'API_TOKEN_HERE' + + +logging.basicConfig(level=logging.DEBUG) + +bot = Bot(token=API_TOKEN) +dp = Dispatcher(bot=bot) + + +# checks specified chat +@dp.message_handler(is_chat_admin=-1001241113577) +async def handle_specified(msg: types.Message): + await msg.answer("You are an admin of the specified chat!") + + +# checks multiple chats +@dp.message_handler(is_chat_admin=[-1001241113577, -320463906]) +async def handle_multiple(msg: types.Message): + await msg.answer("You are an admin of multiple chats!") + + +# checks current chat +@dp.message_handler(is_chat_admin=True) +async def handler3(msg: types.Message): + await msg.answer("You are an admin of the current chat!") + + +if __name__ == '__main__': + executor.start_polling(dp) diff --git a/examples/callback_data_factory.py b/examples/callback_data_factory.py index 8fd197df..9a8affe9 100644 --- a/examples/callback_data_factory.py +++ b/examples/callback_data_factory.py @@ -1,4 +1,3 @@ -import asyncio import logging import random import uuid @@ -21,12 +20,12 @@ dp.middleware.setup(LoggingMiddleware()) POSTS = { str(uuid.uuid4()): { - 'title': f"Post {index}", + 'title': f'Post {index}', 'body': 'Lorem ipsum dolor sit amet, ' 'consectetur adipiscing elit, ' 'sed do eiusmod tempor incididunt ut ' 'labore et dolore magna aliqua', - 'votes': random.randint(-2, 5) + 'votes': random.randint(-2, 5), } for index in range(1, 6) } @@ -42,21 +41,24 @@ def get_keyboard() -> types.InlineKeyboardMarkup: markup.add( types.InlineKeyboardButton( post['title'], - callback_data=posts_cb.new(id=post_id, action='view')) + callback_data=posts_cb.new(id=post_id, action='view')), ) return markup def format_post(post_id: str, post: dict) -> (str, types.InlineKeyboardMarkup): - text = f"{md.hbold(post['title'])}\n" \ - f"{md.quote_html(post['body'])}\n" \ - f"\n" \ - f"Votes: {post['votes']}" + text = md.text( + md.hbold(post['title']), + md.quote_html(post['body']), + '', # just new empty line + f"Votes: {post['votes']}", + sep = '\n', + ) markup = types.InlineKeyboardMarkup() markup.row( types.InlineKeyboardButton('👍', callback_data=posts_cb.new(id=post_id, action='like')), - types.InlineKeyboardButton('👎', callback_data=posts_cb.new(id=post_id, action='unlike')), + types.InlineKeyboardButton('👎', callback_data=posts_cb.new(id=post_id, action='dislike')), ) markup.add(types.InlineKeyboardButton('<< Back', callback_data=posts_cb.new(id='-', action='list'))) return text, markup @@ -84,7 +86,7 @@ async def query_view(query: types.CallbackQuery, callback_data: dict): await query.message.edit_text(text, reply_markup=markup) -@dp.callback_query_handler(posts_cb.filter(action=['like', 'unlike'])) +@dp.callback_query_handler(posts_cb.filter(action=['like', 'dislike'])) async def query_post_vote(query: types.CallbackQuery, callback_data: dict): try: await dp.throttle('vote', rate=1) @@ -100,10 +102,10 @@ async def query_post_vote(query: types.CallbackQuery, callback_data: dict): if action == 'like': post['votes'] += 1 - elif action == 'unlike': + elif action == 'dislike': post['votes'] -= 1 - await query.answer('Voted.') + await query.answer('Vote accepted') text, markup = format_post(post_id, post) await query.message.edit_text(text, reply_markup=markup) @@ -114,4 +116,4 @@ async def message_not_modified_handler(update, error): if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/callback_data_factory_simple.py b/examples/callback_data_factory_simple.py index 2c6a8358..5fc9c548 100644 --- a/examples/callback_data_factory_simple.py +++ b/examples/callback_data_factory_simple.py @@ -27,42 +27,40 @@ likes = {} # user_id: amount_of_likes def get_keyboard(): return types.InlineKeyboardMarkup().row( types.InlineKeyboardButton('👍', callback_data=vote_cb.new(action='up')), - types.InlineKeyboardButton('👎', callback_data=vote_cb.new(action='down'))) + types.InlineKeyboardButton('👎', callback_data=vote_cb.new(action='down')), + ) @dp.message_handler(commands=['start']) async def cmd_start(message: types.Message): amount_of_likes = likes.get(message.from_user.id, 0) # get value if key exists else set to 0 - await message.reply(f'Vote! Now you have {amount_of_likes} votes.', reply_markup=get_keyboard()) + await message.reply(f'Vote! You have {amount_of_likes} votes now.', reply_markup=get_keyboard()) -@dp.callback_query_handler(vote_cb.filter(action='up')) -async def vote_up_cb_handler(query: types.CallbackQuery, callback_data: dict): - logging.info(callback_data) # callback_data contains all info from callback data - likes[query.from_user.id] = likes.get(query.from_user.id, 0) + 1 # update amount of likes in storage - amount_of_likes = likes[query.from_user.id] +@dp.callback_query_handler(vote_cb.filter(action=['up', 'down'])) +async def callback_vote_action(query: types.CallbackQuery, callback_data: dict): + logging.info('Got this callback data: %r', callback_data) # callback_data contains all info from callback data + await query.answer() # don't forget to answer callback query as soon as possible + callback_data_action = callback_data['action'] + likes_count = likes.get(query.from_user.id, 0) - await bot.edit_message_text(f'You voted up! Now you have {amount_of_likes} votes.', - query.from_user.id, - query.message.message_id, - reply_markup=get_keyboard()) + if callback_data_action == 'up': + likes_count += 1 + else: + likes_count -= 1 + likes[query.from_user.id] = likes_count # update amount of likes in storage -@dp.callback_query_handler(vote_cb.filter(action='down')) -async def vote_down_cb_handler(query: types.CallbackQuery, callback_data: dict): - logging.info(callback_data) # callback_data contains all info from callback data - likes[query.from_user.id] = likes.get(query.from_user.id, 0) - 1 # update amount of likes in storage - amount_of_likes = likes[query.from_user.id] - - await bot.edit_message_text(f'You voted down! Now you have {amount_of_likes} votes.', - query.from_user.id, - query.message.message_id, - reply_markup=get_keyboard()) + await bot.edit_message_text( + f'You voted {callback_data_action}! Now you have {likes_count} vote[s].', + query.from_user.id, + query.message.message_id, + reply_markup=get_keyboard(), + ) @dp.errors_handler(exception=MessageNotModified) # handle the cases when this exception raises async def message_not_modified_handler(update, error): - # pass return True diff --git a/examples/check_user_language.py b/examples/check_user_language.py index f59246cf..98bed8a6 100644 --- a/examples/check_user_language.py +++ b/examples/check_user_language.py @@ -2,7 +2,6 @@ Babel is required. """ -import asyncio import logging from aiogram import Bot, Dispatcher, executor, md, types @@ -22,12 +21,13 @@ async def check_language(message: types.Message): await message.reply(md.text( md.bold('Info about your language:'), - md.text(' 🔸', md.bold('Code:'), md.italic(locale.locale)), - md.text(' 🔸', md.bold('Territory:'), md.italic(locale.territory or 'Unknown')), - md.text(' 🔸', md.bold('Language name:'), md.italic(locale.language_name)), - md.text(' 🔸', md.bold('English language name:'), md.italic(locale.english_name)), - sep='\n')) + md.text('🔸', md.bold('Code:'), md.code(locale.language)), + md.text('🔸', md.bold('Territory:'), md.code(locale.territory or 'Unknown')), + md.text('🔸', md.bold('Language name:'), md.code(locale.language_name)), + md.text('🔸', md.bold('English language name:'), md.code(locale.english_name)), + sep='\n', + )) if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 27dc70d9..00046f3a 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -20,7 +20,7 @@ dp = Dispatcher(bot) @dp.message_handler(commands=['start', 'help']) async def send_welcome(message: types.Message): """ - This handler will be called when client send `/start` or `/help` commands. + This handler will be called when user sends `/start` or `/help` command """ await message.reply("Hi!\nI'm EchoBot!\nPowered by aiogram.") @@ -28,13 +28,25 @@ async def send_welcome(message: types.Message): @dp.message_handler(regexp='(^cat[s]?$|puss)') async def cats(message: types.Message): with open('data/cats.jpg', 'rb') as photo: - await bot.send_photo(message.chat.id, photo, caption='Cats is here 😺', - reply_to_message_id=message.message_id) + ''' + # Old fashioned way: + await bot.send_photo( + message.chat.id, + photo, + caption='Cats are here 😺', + reply_to_message_id=message.message_id, + ) + ''' + + await message.reply_photo(photo, caption='Cats are here 😺') @dp.message_handler() async def echo(message: types.Message): - await bot.send_message(message.chat.id, message.text) + # old style: + # await bot.send_message(message.chat.id, message.text) + + await message.reply(message.text, reply=False) if __name__ == '__main__': diff --git a/examples/finite_state_machine_example.py b/examples/finite_state_machine_example.py index 66f89fb2..7c0536a7 100644 --- a/examples/finite_state_machine_example.py +++ b/examples/finite_state_machine_example.py @@ -1,16 +1,19 @@ -import asyncio -from typing import Optional +import logging import aiogram.utils.markdown as md from aiogram import Bot, Dispatcher, types from aiogram.contrib.fsm_storage.memory import MemoryStorage from aiogram.dispatcher import FSMContext +from aiogram.dispatcher.filters import Text from aiogram.dispatcher.filters.state import State, StatesGroup from aiogram.types import ParseMode from aiogram.utils import executor +logging.basicConfig(level=logging.INFO) + API_TOKEN = 'BOT TOKEN HERE' + bot = Bot(token=API_TOKEN) # For example use simple MemoryStorage for Dispatcher. @@ -25,7 +28,7 @@ class Form(StatesGroup): gender = State() # Will be represented in storage as 'Form:gender' -@dp.message_handler(commands=['start']) +@dp.message_handler(commands='start') async def cmd_start(message: types.Message): """ Conversation's entry point @@ -37,19 +40,21 @@ async def cmd_start(message: types.Message): # You can use state '*' if you need to handle all states -@dp.message_handler(state='*', commands=['cancel']) -@dp.message_handler(lambda message: message.text.lower() == 'cancel', state='*') -async def cancel_handler(message: types.Message, state: FSMContext, raw_state: Optional[str] = None): +@dp.message_handler(state='*', commands='cancel') +@dp.message_handler(Text(equals='cancel', ignore_case=True), state='*') +async def cancel_handler(message: types.Message, state: FSMContext): """ Allow user to cancel any action """ - if raw_state is None: + current_state = await state.get_state() + if current_state is None: return + logging.info('Cancelling state %r', current_state) # Cancel state and inform user about it await state.finish() # And remove keyboard (just in case) - await message.reply('Canceled.', reply_markup=types.ReplyKeyboardRemove()) + await message.reply('Cancelled.', reply_markup=types.ReplyKeyboardRemove()) @dp.message_handler(state=Form.name) @@ -66,7 +71,7 @@ async def process_name(message: types.Message, state: FSMContext): # Check age. Age gotta be digit @dp.message_handler(lambda message: not message.text.isdigit(), state=Form.age) -async def failed_process_age(message: types.Message): +async def process_age_invalid(message: types.Message): """ If age is invalid """ @@ -88,11 +93,11 @@ async def process_age(message: types.Message, state: FSMContext): @dp.message_handler(lambda message: message.text not in ["Male", "Female", "Other"], state=Form.gender) -async def failed_process_gender(message: types.Message): +async def process_gender_invalid(message: types.Message): """ In this example gender has to be one of: Male, Female, Other. """ - return await message.reply("Bad gender name. Choose you gender from keyboard.") + return await message.reply("Bad gender name. Choose your gender from the keyboard.") @dp.message_handler(state=Form.gender) @@ -104,11 +109,17 @@ async def process_gender(message: types.Message, state: FSMContext): markup = types.ReplyKeyboardRemove() # And send message - await bot.send_message(message.chat.id, md.text( - md.text('Hi! Nice to meet you,', md.bold(data['name'])), - md.text('Age:', data['age']), - md.text('Gender:', data['gender']), - sep='\n'), reply_markup=markup, parse_mode=ParseMode.MARKDOWN) + await bot.send_message( + message.chat.id, + md.text( + md.text('Hi! Nice to meet you,', md.bold(data['name'])), + md.text('Age:', md.code(data['age'])), + md.text('Gender:', data['gender']), + sep='\n', + ), + reply_markup=markup, + parse_mode=ParseMode.MARKDOWN, + ) # Finish conversation await state.finish() diff --git a/examples/i18n_example.py b/examples/i18n_example.py index bf23c8d1..3bb624bd 100644 --- a/examples/i18n_example.py +++ b/examples/i18n_example.py @@ -37,7 +37,7 @@ from pathlib import Path from aiogram import Bot, Dispatcher, executor, types from aiogram.contrib.middlewares.i18n import I18nMiddleware -TOKEN = 'BOT TOKEN HERE' +TOKEN = 'BOT_TOKEN_HERE' I18N_DOMAIN = 'mybot' BASE_DIR = Path(__file__).parent @@ -54,14 +54,16 @@ dp.middleware.setup(i18n) _ = i18n.gettext -@dp.message_handler(commands=['start']) +@dp.message_handler(commands='start') async def cmd_start(message: types.Message): # Simply use `_('message')` instead of `'message'` and never use f-strings for translatable texts. await message.reply(_('Hello, {user}!').format(user=message.from_user.full_name)) -@dp.message_handler(commands=['lang']) +@dp.message_handler(commands='lang') async def cmd_lang(message: types.Message, locale): + # For setting custom lang you have to modify i18n middleware, like this: + # https://github.com/aiogram/EventsTrackerBot/blob/master/modules/base/middlewares.py await message.reply(_('Your current language: {language}').format(language=locale)) # If you care about pluralization, here's small handler @@ -70,15 +72,27 @@ async def cmd_lang(message: types.Message, locale): # Alias for gettext method, parser will understand double underscore as plural (aka ngettext) __ = i18n.gettext -# Some pseudo numeric value -TOTAL_LIKES = 0 -@dp.message_handler(commands=['like']) +# some likes manager +LIKES_STORAGE = {'count': 0} + + +def get_likes() -> int: + return LIKES_STORAGE['count'] + + +def increase_likes() -> int: + LIKES_STORAGE['count'] += 1 + return get_likes() +# + + +@dp.message_handler(commands='like') async def cmd_like(message: types.Message, locale): - TOTAL_LIKES += 1 + likes = increase_likes() # NOTE: This is comment for a translator - await message.reply(__('Aiogram has {number} like!', 'Aiogram has {number} likes!', TOTAL_LIKES).format(number=TOTAL_LIKES)) + await message.reply(__('Aiogram has {number} like!', 'Aiogram has {number} likes!', likes).format(number=likes)) if __name__ == '__main__': executor.start_polling(dp, skip_updates=True) diff --git a/examples/id_filter_example.py b/examples/id_filter_example.py index 64dc3b3f..343253e3 100644 --- a/examples/id_filter_example.py +++ b/examples/id_filter_example.py @@ -1,37 +1,35 @@ from aiogram import Bot, Dispatcher, executor, types from aiogram.dispatcher.handler import SkipHandler -API_TOKEN = 'API_TOKE_HERE' + +API_TOKEN = 'BOT_TOKEN_HERE' bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) -user_id_to_test = None # todo: Set id here -chat_id_to_test = user_id_to_test +user_id_required = None # TODO: Set id here +chat_id_required = user_id_required # Change for use in groups (user_id == chat_id in pm) -@dp.message_handler(user_id=user_id_to_test) +@dp.message_handler(user_id=user_id_required) async def handler1(msg: types.Message): - await bot.send_message(msg.chat.id, - "Hello, checking with user_id=") - raise SkipHandler + await bot.send_message(msg.chat.id, "Hello, checking with user_id=") + raise SkipHandler # just for demo -@dp.message_handler(chat_id=chat_id_to_test) +@dp.message_handler(chat_id=chat_id_required) async def handler2(msg: types.Message): - await bot.send_message(msg.chat.id, - "Hello, checking with chat_id=") - raise SkipHandler + await bot.send_message(msg.chat.id, "Hello, checking with chat_id=") + raise SkipHandler # just for demo -@dp.message_handler(user_id=user_id_to_test, chat_id=chat_id_to_test) +@dp.message_handler(user_id=user_id_required, chat_id=chat_id_required) async def handler3(msg: types.Message): - await bot.send_message(msg.chat.id, - "Hello from user= & chat_id=") + await msg.reply("Hello from user= & chat_id=", reply=False) -@dp.message_handler(user_id=[user_id_to_test, 123]) # todo: add second id here +@dp.message_handler(user_id=[user_id_required, 42]) # TODO: You can add any number of ids here async def handler4(msg: types.Message): - print("Checked user_id with list!") + await msg.reply("Checked user_id with list!", reply=False) if __name__ == '__main__': diff --git a/examples/inline_bot.py b/examples/inline_bot.py index f1a81bb4..28f83e43 100644 --- a/examples/inline_bot.py +++ b/examples/inline_bot.py @@ -1,9 +1,11 @@ -import asyncio +import hashlib import logging -from aiogram import Bot, types, Dispatcher, executor +from aiogram import Bot, Dispatcher, executor +from aiogram.types import InlineQuery, \ + InputTextMessageContent, InlineQueryResultArticle -API_TOKEN = 'BOT TOKEN HERE' +API_TOKEN = 'BOT_TOKEN_HERE' logging.basicConfig(level=logging.DEBUG) @@ -12,10 +14,22 @@ dp = Dispatcher(bot) @dp.inline_handler() -async def inline_echo(inline_query: types.InlineQuery): - input_content = types.InputTextMessageContent(inline_query.query or 'echo') - item = types.InlineQueryResultArticle(id='1', title='echo', - input_message_content=input_content) +async def inline_echo(inline_query: InlineQuery): + # id affects both preview and content, + # so it has to be unique for each result + # (Unique identifier for this result, 1-64 Bytes) + # you can set your unique id's + # but for example i'll generate it based on text because I know, that + # only text will be passed in this example + text = inline_query.query or 'echo' + input_content = InputTextMessageContent(text) + result_id: str = hashlib.md5(text.encode()).hexdigest() + item = InlineQueryResultArticle( + id=result_id, + title=f'Result {text!r}', + input_message_content=input_content, + ) + # don't forget to set cache_time=1 for testing (default is 300s or 5m) await bot.answer_inline_query(inline_query.id, results=[item], cache_time=1) diff --git a/examples/inline_keyboard_example.py b/examples/inline_keyboard_example.py index 2478b9e0..8b950f98 100644 --- a/examples/inline_keyboard_example.py +++ b/examples/inline_keyboard_example.py @@ -6,50 +6,56 @@ import logging from aiogram import Bot, Dispatcher, executor, types + API_TOKEN = 'BOT_TOKEN_HERE' # Configure logging logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) # Initialize bot and dispatcher bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) -@dp.message_handler(commands=['start']) +@dp.message_handler(commands='start') async def start_cmd_handler(message: types.Message): keyboard_markup = types.InlineKeyboardMarkup(row_width=3) # default row_width is 3, so here we can omit it actually # kept for clearness - keyboard_markup.row(types.InlineKeyboardButton("Yes!", callback_data='yes'), - # in real life for the callback_data the callback data factory should be used - # here the raw string is used for the simplicity - types.InlineKeyboardButton("No!", callback_data='no')) + text_and_data = ( + ('Yes!', 'yes'), + ('No!', 'no'), + ) + # in real life for the callback_data the callback data factory should be used + # here the raw string is used for the simplicity + row_btns = (types.InlineKeyboardButton(text, callback_data=data) for text, data in text_and_data) - keyboard_markup.add(types.InlineKeyboardButton("aiogram link", - url='https://github.com/aiogram/aiogram')) - # url buttons has no callback data + keyboard_markup.row(*row_btns) + keyboard_markup.add( + # url buttons have no callback data + types.InlineKeyboardButton('aiogram source', url='https://github.com/aiogram/aiogram'), + ) await message.reply("Hi!\nDo you love aiogram?", reply_markup=keyboard_markup) -@dp.callback_query_handler(lambda cb: cb.data in ['yes', 'no']) # if cb.data is either 'yes' or 'no' -# @dp.callback_query_handler(text='yes') # if cb.data == 'yes' +# Use multiple registrators. Handler will execute when one of the filters is OK +@dp.callback_query_handler(text='no') # if cb.data == 'no' +@dp.callback_query_handler(text='yes') # if cb.data == 'yes' async def inline_kb_answer_callback_handler(query: types.CallbackQuery): - await query.answer() # send answer to close the rounding circle - answer_data = query.data - logger.debug(f"answer_data={answer_data}") - # here we can work with query.data + # always answer callback queries, even if you have nothing to say + await query.answer(f'You answered with {answer_data!r}') + if answer_data == 'yes': - await bot.send_message(query.from_user.id, "That's great!") + text = 'Great, me too!' elif answer_data == 'no': - await bot.send_message(query.from_user.id, "Oh no...Why so?") + text = 'Oh no...Why so?' else: - await bot.send_message(query.from_user.id, "Invalid callback data!") + text = f'Unexpected callback data {answer_data!r}!' + + await bot.send_message(query.from_user.id, text) if __name__ == '__main__': diff --git a/examples/locales/mybot.pot b/examples/locales/mybot.pot index b0736569..62b2d425 100644 --- a/examples/locales/mybot.pot +++ b/examples/locales/mybot.pot @@ -1,31 +1,26 @@ # Translations template for PROJECT. -# Copyright (C) 2018 ORGANIZATION +# Copyright (C) 2019 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2018. +# FIRST AUTHOR , 2019. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"POT-Creation-Date: 2019-08-10 17:51+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.6.0\n" +"Generated-By: Babel 2.7.0\n" -#: i18n_example.py:48 +#: i18n_example.py:60 msgid "Hello, {user}!" msgstr "" -#: i18n_example.py:53 +#: i18n_example.py:67 msgid "Your current language: {language}" msgstr "" - -msgid "Aiogram has {number} like!" -msgid_plural "Aiogram has {number} likes!" -msgstr[0] "" -msgstr[1] "" diff --git a/examples/locales/ru/LC_MESSAGES/mybot.po b/examples/locales/ru/LC_MESSAGES/mybot.po index 8180af42..9064bc0e 100644 --- a/examples/locales/ru/LC_MESSAGES/mybot.po +++ b/examples/locales/ru/LC_MESSAGES/mybot.po @@ -1,14 +1,14 @@ # Russian translations for PROJECT. -# Copyright (C) 2018 ORGANIZATION +# Copyright (C) 2019 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2018. +# FIRST AUTHOR , 2019. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2018-06-30 03:50+0300\n" -"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"POT-Creation-Date: 2019-08-10 17:51+0300\n" +"PO-Revision-Date: 2019-08-10 17:52+0300\n" "Last-Translator: FULL NAME \n" "Language: ru\n" "Language-Team: ru \n" @@ -17,18 +17,19 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.6.0\n" +"Generated-By: Babel 2.7.0\n" -#: i18n_example.py:48 +#: i18n_example.py:60 msgid "Hello, {user}!" msgstr "Привет, {user}!" -#: i18n_example.py:53 +#: i18n_example.py:67 msgid "Your current language: {language}" msgstr "Твой язык: {language}" +#: i18n_example.py:95 msgid "Aiogram has {number} like!" msgid_plural "Aiogram has {number} likes!" msgstr[0] "Aiogram имеет {number} лайк!" msgstr[1] "Aiogram имеет {number} лайка!" -msgstr[2] "Aiogram имеет {number} лайков!" \ No newline at end of file +msgstr[2] "Aiogram имеет {number} лайков!" diff --git a/examples/media_group.py b/examples/media_group.py index eafbac6a..3d488364 100644 --- a/examples/media_group.py +++ b/examples/media_group.py @@ -2,7 +2,8 @@ import asyncio from aiogram import Bot, Dispatcher, executor, filters, types -API_TOKEN = 'BOT TOKEN HERE' + +API_TOKEN = 'BOT_TOKEN_HERE' bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) @@ -13,10 +14,10 @@ async def send_welcome(message: types.Message): # So... At first I want to send something like this: await message.reply("Do you want to see many pussies? Are you ready?") - # And wait few seconds... + # Wait a little... await asyncio.sleep(1) - # Good bots should send chat actions. Or not. + # Good bots should send chat actions... await types.ChatActions.upload_photo() # Create media group diff --git a/examples/middleware_and_antiflood.py b/examples/middleware_and_antiflood.py index 4a0cc491..72fabf55 100644 --- a/examples/middleware_and_antiflood.py +++ b/examples/middleware_and_antiflood.py @@ -7,7 +7,7 @@ from aiogram.dispatcher.handler import CancelHandler, current_handler from aiogram.dispatcher.middlewares import BaseMiddleware from aiogram.utils.exceptions import Throttled -TOKEN = 'BOT TOKEN HERE' +TOKEN = 'BOT_TOKEN_HERE' # In this example Redis storage is used storage = RedisStorage2(db=5) diff --git a/examples/payments.py b/examples/payments.py index a01fbaf3..42162578 100644 --- a/examples/payments.py +++ b/examples/payments.py @@ -1,13 +1,12 @@ -import asyncio - from aiogram import Bot from aiogram import types from aiogram.dispatcher import Dispatcher from aiogram.types.message import ContentTypes from aiogram.utils import executor -BOT_TOKEN = 'BOT TOKEN HERE' -PAYMENTS_PROVIDER_TOKEN = '123456789:TEST:1234567890abcdef1234567890abcdef' + +BOT_TOKEN = 'BOT_TOKEN_HERE' +PAYMENTS_PROVIDER_TOKEN = '123456789:TEST:1422' bot = Bot(BOT_TOKEN) dp = Dispatcher(bot) @@ -15,13 +14,13 @@ dp = Dispatcher(bot) # Setup prices prices = [ types.LabeledPrice(label='Working Time Machine', amount=5750), - types.LabeledPrice(label='Gift wrapping', amount=500) + types.LabeledPrice(label='Gift wrapping', amount=500), ] # Setup shipping options shipping_options = [ types.ShippingOption(id='instant', title='WorldWide Teleporter').add(types.LabeledPrice('Teleporter', 1000)), - types.ShippingOption(id='pickup', title='Local pickup').add(types.LabeledPrice('Pickup', 300)) + types.ShippingOption(id='pickup', title='Local pickup').add(types.LabeledPrice('Pickup', 300)), ] @@ -59,7 +58,7 @@ async def cmd_buy(message: types.Message): ' Order our Working Time Machine today!', provider_token=PAYMENTS_PROVIDER_TOKEN, currency='usd', - photo_url='https://images.fineartamerica.com/images-medium-large/2-the-time-machine-dmitriy-khristenko.jpg', + photo_url='https://telegra.ph/file/d08ff863531f10bf2ea4b.jpg', photo_height=512, # !=0/None or picture won't be shown photo_width=512, photo_size=512, @@ -69,14 +68,14 @@ async def cmd_buy(message: types.Message): payload='HAPPY FRIDAYS COUPON') -@dp.shipping_query_handler(func=lambda query: True) +@dp.shipping_query_handler(lambda query: True) async def shipping(shipping_query: types.ShippingQuery): await bot.answer_shipping_query(shipping_query.id, ok=True, shipping_options=shipping_options, error_message='Oh, seems like our Dog couriers are having a lunch right now.' ' Try again later!') -@dp.pre_checkout_query_handler(func=lambda query: True) +@dp.pre_checkout_query_handler(lambda query: True) async def checkout(pre_checkout_query: types.PreCheckoutQuery): await bot.answer_pre_checkout_query(pre_checkout_query.id, ok=True, error_message="Aliens tried to steal your card's CVV," @@ -95,4 +94,4 @@ async def got_payment(message: types.Message): if __name__ == '__main__': - executor.start_polling(dp) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/proxy_and_emojize.py b/examples/proxy_and_emojize.py index 17e33872..5ef40608 100644 --- a/examples/proxy_and_emojize.py +++ b/examples/proxy_and_emojize.py @@ -1,4 +1,3 @@ -import asyncio import logging import aiohttp @@ -11,13 +10,13 @@ from aiogram.utils.executor import start_polling from aiogram.utils.markdown import bold, code, italic, text # Configure bot here -API_TOKEN = 'BOT TOKEN HERE' -PROXY_URL = 'http://PROXY_URL' # Or 'socks5://...' +API_TOKEN = 'BOT_TOKEN_HERE' +PROXY_URL = 'http://PROXY_URL' # Or 'socks5://host:port' -# If authentication is required in your proxy then uncomment next line and change login/password for it +# NOTE: If authentication is required in your proxy then uncomment next line and change login/password for it # PROXY_AUTH = aiohttp.BasicAuth(login='login', password='password') -# And add `proxy_auth=PROXY_AUTH` argument in line 25, like this: -# >>> bot = Bot(token=API_TOKEN, loop=loop, proxy=PROXY_URL, proxy_auth=PROXY_AUTH) +# And add `proxy_auth=PROXY_AUTH` argument in line 30, like this: +# >>> bot = Bot(token=API_TOKEN, proxy=PROXY_URL, proxy_auth=PROXY_AUTH) # Also you can use Socks5 proxy but you need manually install aiohttp_socks package. # Get my ip URL @@ -26,26 +25,32 @@ GET_IP_URL = 'http://bot.whatismyipaddress.com/' logging.basicConfig(level=logging.INFO) bot = Bot(token=API_TOKEN, proxy=PROXY_URL) + +# If auth is required: +# bot = Bot(token=API_TOKEN, proxy=PROXY_URL, proxy_auth=PROXY_AUTH) dp = Dispatcher(bot) -async def fetch(url, proxy=None, proxy_auth=None): - async with aiohttp.ClientSession() as session: - async with session.get(url, proxy=proxy, proxy_auth=proxy_auth) as response: - return await response.text() +async def fetch(url, session): + async with session.get(url) as response: + return await response.text() @dp.message_handler(commands=['start']) async def cmd_start(message: types.Message): + # fetching urls will take some time, so notify user that everything is OK + await types.ChatActions.typing() + content = [] # Make request (without proxy) - ip = await fetch(GET_IP_URL) + async with aiohttp.ClientSession() as session: + ip = await fetch(GET_IP_URL, session) content.append(text(':globe_showing_Americas:', bold('IP:'), code(ip))) # This line is formatted to '🌎 *IP:* `YOUR IP`' - # Make request through proxy - ip = await fetch(GET_IP_URL, bot.proxy, bot.proxy_auth) + # Make request through bot's proxy + ip = await fetch(GET_IP_URL, bot.session) content.append(text(':locked_with_key:', bold('IP:'), code(ip), italic('via proxy'))) # This line is formatted to '🔐 *IP:* `YOUR IP` _via proxy_' diff --git a/examples/regexp_commands_filter_example.py b/examples/regexp_commands_filter_example.py index 86ccba55..05de9dd8 100644 --- a/examples/regexp_commands_filter_example.py +++ b/examples/regexp_commands_filter_example.py @@ -2,14 +2,28 @@ from aiogram import Bot, types from aiogram.dispatcher import Dispatcher, filters from aiogram.utils import executor -bot = Bot(token='TOKEN') + +bot = Bot(token='BOT_TOKEN_HERE', parse_mode=types.ParseMode.HTML) dp = Dispatcher(bot) @dp.message_handler(filters.RegexpCommandsFilter(regexp_commands=['item_([0-9]*)'])) async def send_welcome(message: types.Message, regexp_command): - await message.reply("You have requested an item with number: {}".format(regexp_command.group(1))) + await message.reply(f"You have requested an item with id {regexp_command.group(1)}") + + +@dp.message_handler(commands='start') +async def create_deeplink(message: types.Message): + bot_user = await bot.me + bot_username = bot_user.username + deeplink = f'https://t.me/{bot_username}?start=item_12345' + text = ( + f'Either send a command /item_1234 or follow this link {deeplink} and then click start\n' + 'It also can be hidden in a inline button\n\n' + 'Or just send /start item_123' + ) + await message.reply(text, disable_web_page_preview=True) if __name__ == '__main__': - executor.start_polling(dp) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/regular_keyboard_example.py b/examples/regular_keyboard_example.py index 350e007e..d111053c 100644 --- a/examples/regular_keyboard_example.py +++ b/examples/regular_keyboard_example.py @@ -6,6 +6,7 @@ import logging from aiogram import Bot, Dispatcher, executor, types + API_TOKEN = 'BOT_TOKEN_HERE' # Configure logging @@ -18,24 +19,27 @@ bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) -@dp.message_handler(commands=['start']) +@dp.message_handler(commands='start') async def start_cmd_handler(message: types.Message): keyboard_markup = types.ReplyKeyboardMarkup(row_width=3) # default row_width is 3, so here we can omit it actually # kept for clearness - keyboard_markup.row(types.KeyboardButton("Yes!"), - types.KeyboardButton("No!")) + btns_text = ('Yes!', 'No!') + keyboard_markup.row(*(types.KeyboardButton(text) for text in btns_text)) # adds buttons as a new row to the existing keyboard # the behaviour doesn't depend on row_width attribute - keyboard_markup.add(types.KeyboardButton("I don't know"), - types.KeyboardButton("Who am i?"), - types.KeyboardButton("Where am i?"), - types.KeyboardButton("Who is there?")) - # adds buttons. New rows is formed according to row_width parameter + more_btns_text = ( + "I don't know", + "Who am i?", + "Where am i?", + "Who is there?", + ) + keyboard_markup.add(*(types.KeyboardButton(text) for text in more_btns_text)) + # adds buttons. New rows are formed according to row_width parameter - await message.reply("Hi!\nDo you love aiogram?", reply_markup=keyboard_markup) + await message.reply("Hi!\nDo you like aiogram?", reply_markup=keyboard_markup) @dp.message_handler() @@ -45,15 +49,17 @@ async def all_msg_handler(message: types.Message): # in real bot, it's better to define message_handler(text="...") for each button # but here for the simplicity only one handler is defined - text_of_button = message.text - logger.debug(text_of_button) # print the text we got + button_text = message.text + logger.debug('The answer is %r', button_text) # print the text we've got - if text_of_button == 'Yes!': - await message.reply("That's great", reply_markup=types.ReplyKeyboardRemove()) - elif text_of_button == 'No!': - await message.reply("Oh no! Why?", reply_markup=types.ReplyKeyboardRemove()) + if button_text == 'Yes!': + reply_text = "That's great" + elif button_text == 'No!': + reply_text = "Oh no! Why?" else: - await message.reply("Keep calm...Everything is fine", reply_markup=types.ReplyKeyboardRemove()) + reply_text = "Keep calm...Everything is fine" + + await message.reply(reply_text, reply_markup=types.ReplyKeyboardRemove()) # with message, we send types.ReplyKeyboardRemove() to hide the keyboard diff --git a/examples/text_filter_example.py b/examples/text_filter_example.py new file mode 100644 index 00000000..60d631e3 --- /dev/null +++ b/examples/text_filter_example.py @@ -0,0 +1,53 @@ +""" +This is a bot to show the usage of the builtin Text filter +Instead of a list, a single element can be passed to any filter, it will be treated as list with an element +""" + +import logging + +from aiogram import Bot, Dispatcher, executor, types + + +API_TOKEN = 'BOT_TOKEN_HERE' + +# Configure logging +logging.basicConfig(level=logging.INFO) + +# Initialize bot and dispatcher +bot = Bot(token=API_TOKEN) +dp = Dispatcher(bot) + + +# if the text from user in the list +@dp.message_handler(text=['text1', 'text2']) +async def text_in_handler(message: types.Message): + await message.answer("The message text equals to one of in the list!") + + +# if the text contains any string +@dp.message_handler(text_contains='example1') +@dp.message_handler(text_contains='example2') +async def text_contains_any_handler(message: types.Message): + await message.answer("The message text contains any of strings") + + +# if the text contains all the strings from the list +@dp.message_handler(text_contains=['str1', 'str2']) +async def text_contains_all_handler(message: types.Message): + await message.answer("The message text contains all strings from the list") + + +# if the text starts with any string from the list +@dp.message_handler(text_startswith=['prefix1', 'prefix2']) +async def text_startswith_handler(message: types.Message): + await message.answer("The message text starts with any of prefixes") + + +# if the text ends with any string from the list +@dp.message_handler(text_endswith=['postfix1', 'postfix2']) +async def text_endswith_handler(message: types.Message): + await message.answer("The message text ends with any of postfixes") + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/throttling_example.py b/examples/throttling_example.py index 3c780974..f9ad1c67 100644 --- a/examples/throttling_example.py +++ b/examples/throttling_example.py @@ -12,6 +12,7 @@ from aiogram.dispatcher import Dispatcher from aiogram.utils.exceptions import Throttled from aiogram.utils.executor import start_polling + API_TOKEN = 'BOT_TOKEN_HERE' logging.basicConfig(level=logging.INFO) @@ -19,7 +20,7 @@ logging.basicConfig(level=logging.INFO) bot = Bot(token=API_TOKEN) # Throttling manager does not work without Leaky Bucket. -# Then need to use storages. For example use simple in-memory storage. +# You need to use a storage. For example use simple in-memory storage. storage = MemoryStorage() dp = Dispatcher(bot, storage=storage) diff --git a/examples/webhook_example.py b/examples/webhook_example.py index 0f6ae3cd..6efa8767 100644 --- a/examples/webhook_example.py +++ b/examples/webhook_example.py @@ -1,176 +1,66 @@ -""" -Example outdated -""" +import logging -import asyncio -import ssl -import sys - -from aiohttp import web - -import aiogram from aiogram import Bot, types -from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.contrib.middlewares.logging import LoggingMiddleware from aiogram.dispatcher import Dispatcher -from aiogram.dispatcher.webhook import get_new_configured_app, SendMessage -from aiogram.types import ChatType, ParseMode, ContentTypes -from aiogram.utils.markdown import hbold, bold, text, link +from aiogram.dispatcher.webhook import SendMessage +from aiogram.utils.executor import start_webhook -TOKEN = 'BOT TOKEN HERE' -WEBHOOK_HOST = 'example.com' # Domain name or IP addres which your bot is located. -WEBHOOK_PORT = 443 # Telegram Bot API allows only for usage next ports: 443, 80, 88 or 8443 -WEBHOOK_URL_PATH = '/webhook' # Part of URL +API_TOKEN = 'BOT_TOKEN_HERE' -# This options needed if you use self-signed SSL certificate -# Instructions: https://core.telegram.org/bots/self-signed -WEBHOOK_SSL_CERT = './webhook_cert.pem' # Path to the ssl certificate -WEBHOOK_SSL_PRIV = './webhook_pkey.pem' # Path to the ssl private key +# webhook settings +WEBHOOK_HOST = 'https://your.domain' +WEBHOOK_PATH = '/path/to/api' +WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}" -WEBHOOK_URL = f"https://{WEBHOOK_HOST}:{WEBHOOK_PORT}{WEBHOOK_URL_PATH}" - -# Web app settings: -# Use LAN address to listen webhooks -# User any available port in range from 1024 to 49151 if you're using proxy, or WEBHOOK_PORT if you're using direct webhook handling -WEBAPP_HOST = 'localhost' +# webserver settings +WEBAPP_HOST = 'localhost' # or ip WEBAPP_PORT = 3001 -BAD_CONTENT = ContentTypes.PHOTO & ContentTypes.DOCUMENT & ContentTypes.STICKER & ContentTypes.AUDIO +logging.basicConfig(level=logging.INFO) -bot = Bot(TOKEN) -storage = MemoryStorage() -dp = Dispatcher(bot, storage=storage) +bot = Bot(token=API_TOKEN) +dp = Dispatcher(bot) +dp.middleware.setup(LoggingMiddleware()) -async def cmd_start(message: types.Message): - # Yep. aiogram allows to respond into webhook. - # https://core.telegram.org/bots/api#making-requests-when-getting-updates - return SendMessage(chat_id=message.chat.id, text='Hi from webhook!', - reply_to_message_id=message.message_id) +@dp.message_handler() +async def echo(message: types.Message): + # Regular request + # await bot.send_message(message.chat.id, message.text) + + # or reply INTO webhook + return SendMessage(message.chat.id, message.text) -async def cmd_about(message: types.Message): - # In this function markdown utils are userd for formatting message text - return SendMessage(message.chat.id, text( - bold('Hi! I\'m just a simple telegram bot.'), - '', - text('I\'m powered by', bold('Python', Version(*sys.version_info[:]))), - text('With', link(text('aiogram', aiogram.VERSION), 'https://github.com/aiogram/aiogram')), - sep='\n' - ), parse_mode=ParseMode.MARKDOWN) +async def on_startup(dp): + await bot.set_webhook(WEBHOOK_URL) + # insert code here to run it after start -async def cancel(message: types.Message): - # Get current state context - state = dp.current_state(chat=message.chat.id, user=message.from_user.id) +async def on_shutdown(dp): + logging.warning('Shutting down..') - # If current user in any state - cancel it. - if await state.get_state() is not None: - await state.set_state(state=None) - return SendMessage(message.chat.id, 'Current action is canceled.') - # Otherwise do nothing + # insert code here to run it before shutdown - -async def unknown(message: types.Message): - """ - Handler for unknown messages. - """ - return SendMessage(message.chat.id, - f"I don\'t know what to do with content type `{message.content_type()}`. Sorry :c") - - -async def cmd_id(message: types.Message): - """ - Return info about user. - """ - if message.reply_to_message: - target = message.reply_to_message.from_user - chat = message.chat - elif message.forward_from and message.chat.type == ChatType.PRIVATE: - target = message.forward_from - chat = message.forward_from or message.chat - else: - target = message.from_user - chat = message.chat - - result_msg = [hbold('Info about user:'), - f"First name: {target.first_name}"] - if target.last_name: - result_msg.append(f"Last name: {target.last_name}") - if target.username: - result_msg.append(f"Username: {target.mention}") - result_msg.append(f"User ID: {target.id}") - - result_msg.extend([hbold('Chat:'), - f"Type: {chat.type}", - f"Chat ID: {chat.id}"]) - if chat.type != ChatType.PRIVATE: - result_msg.append(f"Title: {chat.title}") - else: - result_msg.append(f"Title: {chat.full_name}") - return SendMessage(message.chat.id, '\n'.join(result_msg), reply_to_message_id=message.message_id, - parse_mode=ParseMode.HTML) - - -async def on_startup(app): - # Demonstrate one of the available methods for registering handlers - # This command available only in main state (state=None) - dp.register_message_handler(cmd_start, commands=['start']) - - # This handler is available in all states at any time. - dp.register_message_handler(cmd_about, commands=['help', 'about'], state='*') - dp.register_message_handler(unknown, content_types=BAD_CONTENT, - func=lambda message: message.chat.type == ChatType.PRIVATE) - - # You are able to register one function handler for multiple conditions - dp.register_message_handler(cancel, commands=['cancel'], state='*') - dp.register_message_handler(cancel, func=lambda message: message.text.lower().strip() in ['cancel'], state='*') - - dp.register_message_handler(cmd_id, commands=['id'], state='*') - dp.register_message_handler(cmd_id, func=lambda message: message.forward_from or - message.reply_to_message and - message.chat.type == ChatType.PRIVATE, state='*') - - # Get current webhook status - webhook = await bot.get_webhook_info() - - # If URL is bad - if webhook.url != WEBHOOK_URL: - # If URL doesnt match current - remove webhook - if not webhook.url: - await bot.delete_webhook() - - # Set new URL for webhook - await bot.set_webhook(WEBHOOK_URL, certificate=open(WEBHOOK_SSL_CERT, 'rb')) - # If you want to use free certificate signed by LetsEncrypt you need to set only URL without sending certificate. - - -async def on_shutdown(app): - """ - Graceful shutdown. This method is recommended by aiohttp docs. - """ - # Remove webhook. + # Remove webhook (not acceptable in some cases) await bot.delete_webhook() - # Close Redis connection. + # Close DB connection (if used) await dp.storage.close() await dp.storage.wait_closed() + logging.warning('Bye!') + if __name__ == '__main__': - # Get instance of :class:`aiohttp.web.Application` with configured router. - app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) - - # Setup event handlers. - app.on_startup.append(on_startup) - app.on_shutdown.append(on_shutdown) - - # Generate SSL context - context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) - context.load_cert_chain(WEBHOOK_SSL_CERT, WEBHOOK_SSL_PRIV) - - # Start web-application. - web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT, ssl_context=context) - # Note: - # If you start your bot using nginx or Apache web server, SSL context is not required. - # Otherwise you need to set `ssl_context` parameter. + start_webhook( + dispatcher=dp, + webhook_path=WEBHOOK_PATH, + on_startup=on_startup, + on_shutdown=on_shutdown, + skip_updates=True, + host=WEBAPP_HOST, + port=WEBAPP_PORT, + ) diff --git a/examples/webhook_example_2.py b/examples/webhook_example_2.py deleted file mode 100644 index e2d9225c..00000000 --- a/examples/webhook_example_2.py +++ /dev/null @@ -1,42 +0,0 @@ -import asyncio -import logging - -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.utils.executor import start_webhook - -API_TOKEN = 'BOT TOKEN HERE' - -# webhook settings -WEBHOOK_HOST = 'https://your.domain' -WEBHOOK_PATH = '/path/to/api' -WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}" - -# webserver settings -WEBAPP_HOST = 'localhost' # or ip -WEBAPP_PORT = 3001 - -logging.basicConfig(level=logging.INFO) - -bot = Bot(token=API_TOKEN) -dp = Dispatcher(bot) - - -@dp.message_handler() -async def echo(message: types.Message): - await bot.send_message(message.chat.id, message.text) - - -async def on_startup(dp): - await bot.set_webhook(WEBHOOK_URL) - # insert code here to run it after start - - -async def on_shutdown(dp): - # insert code here to run it before shutdown - pass - - -if __name__ == '__main__': - start_webhook(dispatcher=dp, webhook_path=WEBHOOK_PATH, on_startup=on_startup, on_shutdown=on_shutdown, - skip_updates=True, host=WEBAPP_HOST, port=WEBAPP_PORT) diff --git a/examples/webhook_example_old.py b/examples/webhook_example_old.py new file mode 100644 index 00000000..0f6ae3cd --- /dev/null +++ b/examples/webhook_example_old.py @@ -0,0 +1,176 @@ +""" +Example outdated +""" + +import asyncio +import ssl +import sys + +from aiohttp import web + +import aiogram +from aiogram import Bot, types +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.dispatcher import Dispatcher +from aiogram.dispatcher.webhook import get_new_configured_app, SendMessage +from aiogram.types import ChatType, ParseMode, ContentTypes +from aiogram.utils.markdown import hbold, bold, text, link + +TOKEN = 'BOT TOKEN HERE' + +WEBHOOK_HOST = 'example.com' # Domain name or IP addres which your bot is located. +WEBHOOK_PORT = 443 # Telegram Bot API allows only for usage next ports: 443, 80, 88 or 8443 +WEBHOOK_URL_PATH = '/webhook' # Part of URL + +# This options needed if you use self-signed SSL certificate +# Instructions: https://core.telegram.org/bots/self-signed +WEBHOOK_SSL_CERT = './webhook_cert.pem' # Path to the ssl certificate +WEBHOOK_SSL_PRIV = './webhook_pkey.pem' # Path to the ssl private key + +WEBHOOK_URL = f"https://{WEBHOOK_HOST}:{WEBHOOK_PORT}{WEBHOOK_URL_PATH}" + +# Web app settings: +# Use LAN address to listen webhooks +# User any available port in range from 1024 to 49151 if you're using proxy, or WEBHOOK_PORT if you're using direct webhook handling +WEBAPP_HOST = 'localhost' +WEBAPP_PORT = 3001 + +BAD_CONTENT = ContentTypes.PHOTO & ContentTypes.DOCUMENT & ContentTypes.STICKER & ContentTypes.AUDIO + +bot = Bot(TOKEN) +storage = MemoryStorage() +dp = Dispatcher(bot, storage=storage) + + +async def cmd_start(message: types.Message): + # Yep. aiogram allows to respond into webhook. + # https://core.telegram.org/bots/api#making-requests-when-getting-updates + return SendMessage(chat_id=message.chat.id, text='Hi from webhook!', + reply_to_message_id=message.message_id) + + +async def cmd_about(message: types.Message): + # In this function markdown utils are userd for formatting message text + return SendMessage(message.chat.id, text( + bold('Hi! I\'m just a simple telegram bot.'), + '', + text('I\'m powered by', bold('Python', Version(*sys.version_info[:]))), + text('With', link(text('aiogram', aiogram.VERSION), 'https://github.com/aiogram/aiogram')), + sep='\n' + ), parse_mode=ParseMode.MARKDOWN) + + +async def cancel(message: types.Message): + # Get current state context + state = dp.current_state(chat=message.chat.id, user=message.from_user.id) + + # If current user in any state - cancel it. + if await state.get_state() is not None: + await state.set_state(state=None) + return SendMessage(message.chat.id, 'Current action is canceled.') + # Otherwise do nothing + + +async def unknown(message: types.Message): + """ + Handler for unknown messages. + """ + return SendMessage(message.chat.id, + f"I don\'t know what to do with content type `{message.content_type()}`. Sorry :c") + + +async def cmd_id(message: types.Message): + """ + Return info about user. + """ + if message.reply_to_message: + target = message.reply_to_message.from_user + chat = message.chat + elif message.forward_from and message.chat.type == ChatType.PRIVATE: + target = message.forward_from + chat = message.forward_from or message.chat + else: + target = message.from_user + chat = message.chat + + result_msg = [hbold('Info about user:'), + f"First name: {target.first_name}"] + if target.last_name: + result_msg.append(f"Last name: {target.last_name}") + if target.username: + result_msg.append(f"Username: {target.mention}") + result_msg.append(f"User ID: {target.id}") + + result_msg.extend([hbold('Chat:'), + f"Type: {chat.type}", + f"Chat ID: {chat.id}"]) + if chat.type != ChatType.PRIVATE: + result_msg.append(f"Title: {chat.title}") + else: + result_msg.append(f"Title: {chat.full_name}") + return SendMessage(message.chat.id, '\n'.join(result_msg), reply_to_message_id=message.message_id, + parse_mode=ParseMode.HTML) + + +async def on_startup(app): + # Demonstrate one of the available methods for registering handlers + # This command available only in main state (state=None) + dp.register_message_handler(cmd_start, commands=['start']) + + # This handler is available in all states at any time. + dp.register_message_handler(cmd_about, commands=['help', 'about'], state='*') + dp.register_message_handler(unknown, content_types=BAD_CONTENT, + func=lambda message: message.chat.type == ChatType.PRIVATE) + + # You are able to register one function handler for multiple conditions + dp.register_message_handler(cancel, commands=['cancel'], state='*') + dp.register_message_handler(cancel, func=lambda message: message.text.lower().strip() in ['cancel'], state='*') + + dp.register_message_handler(cmd_id, commands=['id'], state='*') + dp.register_message_handler(cmd_id, func=lambda message: message.forward_from or + message.reply_to_message and + message.chat.type == ChatType.PRIVATE, state='*') + + # Get current webhook status + webhook = await bot.get_webhook_info() + + # If URL is bad + if webhook.url != WEBHOOK_URL: + # If URL doesnt match current - remove webhook + if not webhook.url: + await bot.delete_webhook() + + # Set new URL for webhook + await bot.set_webhook(WEBHOOK_URL, certificate=open(WEBHOOK_SSL_CERT, 'rb')) + # If you want to use free certificate signed by LetsEncrypt you need to set only URL without sending certificate. + + +async def on_shutdown(app): + """ + Graceful shutdown. This method is recommended by aiohttp docs. + """ + # Remove webhook. + await bot.delete_webhook() + + # Close Redis connection. + await dp.storage.close() + await dp.storage.wait_closed() + + +if __name__ == '__main__': + # Get instance of :class:`aiohttp.web.Application` with configured router. + app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) + + # Setup event handlers. + app.on_startup.append(on_startup) + app.on_shutdown.append(on_shutdown) + + # Generate SSL context + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + context.load_cert_chain(WEBHOOK_SSL_CERT, WEBHOOK_SSL_PRIV) + + # Start web-application. + web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT, ssl_context=context) + # Note: + # If you start your bot using nginx or Apache web server, SSL context is not required. + # Otherwise you need to set `ssl_context` parameter. diff --git a/tests/test_dispatcher/test_filters/test_builtin.py b/tests/test_dispatcher/test_filters/test_builtin.py new file mode 100644 index 00000000..86344cec --- /dev/null +++ b/tests/test_dispatcher/test_filters/test_builtin.py @@ -0,0 +1,18 @@ +import pytest + +from aiogram.dispatcher.filters.builtin import Text + + +class TestText: + + @pytest.mark.parametrize('param, key', [ + ('text', 'equals'), + ('text_contains', 'contains'), + ('text_startswith', 'startswith'), + ('text_endswith', 'endswith'), + ]) + def test_validate(self, param, key): + value = 'spam and eggs' + config = {param: value} + res = Text.validate(config) + assert res == {key: value} diff --git a/tests/test_dispatcher/test_filters/test_state.py b/tests/test_dispatcher/test_filters/test_state.py new file mode 100644 index 00000000..b7f5a5fd --- /dev/null +++ b/tests/test_dispatcher/test_filters/test_state.py @@ -0,0 +1,18 @@ +from aiogram.dispatcher.filters.state import StatesGroup + +class TestStatesGroup: + + def test_all_childs(self): + + class InnerState1(StatesGroup): + pass + + class InnerState2(InnerState1): + pass + + class Form(StatesGroup): + inner1 = InnerState1 + inner2 = InnerState2 + + form_childs = Form.all_childs + assert form_childs == (InnerState1, InnerState2) diff --git a/tests/test_filters.py b/tests/test_filters.py index da530910..609db736 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -4,38 +4,35 @@ from aiogram.dispatcher.filters import Text from aiogram.types import Message, CallbackQuery, InlineQuery, Poll +def data_sample_1(): + return [ + ('', ''), + ('', 'exAmple_string'), + + ('example_string', 'example_string'), + ('example_string', 'exAmple_string'), + ('exAmple_string', 'example_string'), + + ('example_string', 'example_string_dsf'), + ('example_string', 'example_striNG_dsf'), + ('example_striNG', 'example_string_dsf'), + + ('example_string', 'not_example_string'), + ('example_string', 'not_eXample_string'), + ('EXample_string', 'not_example_string'), + ] + class TestTextFilter: + + async def _run_check(self, check, test_text): + assert await check(Message(text=test_text)) + assert await check(CallbackQuery(data=test_text)) + assert await check(InlineQuery(query=test_text)) + assert await check(Poll(question=test_text)) + @pytest.mark.asyncio - @pytest.mark.parametrize("test_prefix, test_text, ignore_case", - [('', '', True), - ('', 'exAmple_string', True), - ('', '', False), - ('', 'exAmple_string', False), - - ('example_string', 'example_string', True), - ('example_string', 'exAmple_string', True), - ('exAmple_string', 'example_string', True), - - ('example_string', 'example_string', False), - ('example_string', 'exAmple_string', False), - ('exAmple_string', 'example_string', False), - - ('example_string', 'example_string_dsf', True), - ('example_string', 'example_striNG_dsf', True), - ('example_striNG', 'example_string_dsf', True), - - ('example_string', 'example_string_dsf', False), - ('example_string', 'example_striNG_dsf', False), - ('example_striNG', 'example_string_dsf', False), - - ('example_string', 'not_example_string', True), - ('example_string', 'not_eXample_string', True), - ('EXample_string', 'not_example_string', True), - - ('example_string', 'not_example_string', False), - ('example_string', 'not_eXample_string', False), - ('EXample_string', 'not_example_string', False), - ]) + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_prefix, test_text", data_sample_1()) async def test_startswith(self, test_prefix, test_text, ignore_case): test_filter = Text(startswith=test_prefix, ignore_case=ignore_case) @@ -50,42 +47,45 @@ class TestTextFilter: return result is _test_text.startswith(_test_prefix) - assert await check(Message(text=test_text)) - assert await check(CallbackQuery(data=test_text)) - assert await check(InlineQuery(query=test_text)) - assert await check(Poll(question=test_text)) + await self._run_check(check, test_text) @pytest.mark.asyncio - @pytest.mark.parametrize("test_postfix, test_text, ignore_case", - [('', '', True), - ('', 'exAmple_string', True), - ('', '', False), - ('', 'exAmple_string', False), + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_prefix_list, test_text", [ + (['not_example', ''], ''), + (['', 'not_example'], 'exAmple_string'), - ('example_string', 'example_string', True), - ('example_string', 'exAmple_string', True), - ('exAmple_string', 'example_string', True), + (['not_example', 'example_string'], 'example_string'), + (['example_string', 'not_example'], 'exAmple_string'), + (['not_example', 'exAmple_string'], 'example_string'), - ('example_string', 'example_string', False), - ('example_string', 'exAmple_string', False), - ('exAmple_string', 'example_string', False), + (['not_example', 'example_string'], 'example_string_dsf'), + (['example_string', 'not_example'], 'example_striNG_dsf'), + (['not_example', 'example_striNG'], 'example_string_dsf'), - ('example_string', 'example_string_dsf', True), - ('example_string', 'example_striNG_dsf', True), - ('example_striNG', 'example_string_dsf', True), + (['not_example', 'example_string'], 'not_example_string'), + (['example_string', 'not_example'], 'not_eXample_string'), + (['not_example', 'EXample_string'], 'not_example_string'), + ]) + async def test_startswith_list(self, test_prefix_list, test_text, ignore_case): + test_filter = Text(startswith=test_prefix_list, ignore_case=ignore_case) - ('example_string', 'example_string_dsf', False), - ('example_string', 'example_striNG_dsf', False), - ('example_striNG', 'example_string_dsf', False), + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_prefix_list = map(str.lower, test_prefix_list) + _test_text = test_text.lower() + else: + _test_prefix_list = test_prefix_list + _test_text = test_text - ('example_string', 'not_example_string', True), - ('example_string', 'not_eXample_string', True), - ('EXample_string', 'not_eXample_string', True), + return result is any(map(_test_text.startswith, _test_prefix_list)) - ('example_string', 'not_example_string', False), - ('example_string', 'not_eXample_string', False), - ('EXample_string', 'not_example_string', False), - ]) + await self._run_check(check, test_text) + + @pytest.mark.asyncio + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_postfix, test_text", data_sample_1()) async def test_endswith(self, test_postfix, test_text, ignore_case): test_filter = Text(endswith=test_postfix, ignore_case=ignore_case) @@ -100,42 +100,59 @@ class TestTextFilter: return result is _test_text.endswith(_test_postfix) - assert await check(Message(text=test_text)) - assert await check(CallbackQuery(data=test_text)) - assert await check(InlineQuery(query=test_text)) - assert await check(Poll(question=test_text)) + await self._run_check(check, test_text) @pytest.mark.asyncio - @pytest.mark.parametrize("test_string, test_text, ignore_case", - [('', '', True), - ('', 'exAmple_string', True), - ('', '', False), - ('', 'exAmple_string', False), + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_postfix_list, test_text", [ + (['', 'not_example'], ''), + (['not_example', ''], 'exAmple_string'), - ('example_string', 'example_string', True), - ('example_string', 'exAmple_string', True), - ('exAmple_string', 'example_string', True), + (['example_string', 'not_example'], 'example_string'), + (['not_example', 'example_string'], 'exAmple_string'), + (['exAmple_string', 'not_example'], 'example_string'), - ('example_string', 'example_string', False), - ('example_string', 'exAmple_string', False), - ('exAmple_string', 'example_string', False), + (['not_example', 'example_string'], 'example_string_dsf'), + (['example_string', 'not_example'], 'example_striNG_dsf'), + (['not_example', 'example_striNG'], 'example_string_dsf'), - ('example_string', 'example_string_dsf', True), - ('example_string', 'example_striNG_dsf', True), - ('example_striNG', 'example_string_dsf', True), + (['not_example', 'example_string'], 'not_example_string'), + (['example_string', 'not_example'], 'not_eXample_string'), + (['not_example', 'EXample_string'], 'not_example_string'), + ]) + async def test_endswith_list(self, test_postfix_list, test_text, ignore_case): + test_filter = Text(endswith=test_postfix_list, ignore_case=ignore_case) - ('example_string', 'example_string_dsf', False), - ('example_string', 'example_striNG_dsf', False), - ('example_striNG', 'example_string_dsf', False), + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_postfix_list = map(str.lower, test_postfix_list) + _test_text = test_text.lower() + else: + _test_postfix_list = test_postfix_list + _test_text = test_text - ('example_string', 'not_example_strin', True), - ('example_string', 'not_eXample_strin', True), - ('EXample_string', 'not_eXample_strin', True), + return result is any(map(_test_text.endswith, _test_postfix_list)) + await self._run_check(check, test_text) - ('example_string', 'not_example_strin', False), - ('example_string', 'not_eXample_strin', False), - ('EXample_string', 'not_example_strin', False), - ]) + @pytest.mark.asyncio + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_string, test_text", [ + ('', ''), + ('', 'exAmple_string'), + + ('example_string', 'example_string'), + ('example_string', 'exAmple_string'), + ('exAmple_string', 'example_string'), + + ('example_string', 'example_string_dsf'), + ('example_string', 'example_striNG_dsf'), + ('example_striNG', 'example_string_dsf'), + + ('example_string', 'not_example_strin'), + ('example_string', 'not_eXample_strin'), + ('EXample_string', 'not_example_strin'), + ]) async def test_contains(self, test_string, test_text, ignore_case): test_filter = Text(contains=test_string, ignore_case=ignore_case) @@ -150,34 +167,46 @@ class TestTextFilter: return result is (_test_string in _test_text) - assert await check(Message(text=test_text)) - assert await check(CallbackQuery(data=test_text)) - assert await check(InlineQuery(query=test_text)) - assert await check(Poll(question=test_text)) + await self._run_check(check, test_text) @pytest.mark.asyncio - @pytest.mark.parametrize("test_filter_text, test_text, ignore_case", - [('', '', True), - ('', 'exAmple_string', True), - ('', '', False), - ('', 'exAmple_string', False), + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_filter_list, test_text", [ + (['a', 'ab', 'abc'], 'A'), + (['a', 'ab', 'abc'], 'ab'), + (['a', 'ab', 'abc'], 'aBc'), + (['a', 'ab', 'abc'], 'd'), + ]) + async def test_contains_list(self, test_filter_list, test_text, ignore_case): + test_filter = Text(contains=test_filter_list, ignore_case=ignore_case) - ('example_string', 'example_string', True), - ('example_string', 'exAmple_string', True), - ('exAmple_string', 'example_string', True), + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_filter_list = list(map(str.lower, test_filter_list)) + _test_text = test_text.lower() + else: + _test_filter_list = test_filter_list + _test_text = test_text - ('example_string', 'example_string', False), - ('example_string', 'exAmple_string', False), - ('exAmple_string', 'example_string', False), + return result is all(map(_test_text.__contains__, _test_filter_list)) - ('example_string', 'not_example_string', True), - ('example_string', 'not_eXample_string', True), - ('EXample_string', 'not_eXample_string', True), + await self._run_check(check, test_text) - ('example_string', 'not_example_string', False), - ('example_string', 'not_eXample_string', False), - ('EXample_string', 'not_example_string', False), - ]) + @pytest.mark.asyncio + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_filter_text, test_text", [ + ('', ''), + ('', 'exAmple_string'), + + ('example_string', 'example_string'), + ('example_string', 'exAmple_string'), + ('exAmple_string', 'example_string'), + + ('example_string', 'not_example_string'), + ('example_string', 'not_eXample_string'), + ('EXample_string', 'not_example_string'), + ]) async def test_equals_string(self, test_filter_text, test_text, ignore_case): test_filter = Text(equals=test_filter_text, ignore_case=ignore_case) @@ -191,7 +220,44 @@ class TestTextFilter: _test_text = test_text return result is (_test_text == _test_filter_text) - assert await check(Message(text=test_text)) - assert await check(CallbackQuery(data=test_text)) - assert await check(InlineQuery(query=test_text)) - assert await check(Poll(question=test_text)) + await self._run_check(check, test_text) + + @pytest.mark.asyncio + @pytest.mark.parametrize('ignore_case', (True, False)) + @pytest.mark.parametrize("test_filter_list, test_text", [ + (['new_string', ''], ''), + (['', 'new_string'], 'exAmple_string'), + + (['example_string'], 'example_string'), + (['example_string'], 'exAmple_string'), + (['exAmple_string'], 'example_string'), + + (['example_string'], 'not_example_string'), + (['example_string'], 'not_eXample_string'), + (['EXample_string'], 'not_example_string'), + + (['example_string', 'new_string'], 'example_string'), + (['new_string', 'example_string'], 'exAmple_string'), + (['exAmple_string', 'new_string'], 'example_string'), + + (['example_string', 'new_string'], 'not_example_string'), + (['new_string', 'example_string'], 'not_eXample_string'), + (['EXample_string', 'new_string'], 'not_example_string'), + ]) + async def test_equals_list(self, test_filter_list, test_text, ignore_case): + test_filter = Text(equals=test_filter_list, ignore_case=ignore_case) + + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_filter_list = list(map(str.lower, test_filter_list)) + _test_text = test_text.lower() + else: + _test_filter_list = test_filter_list + _test_text = test_text + assert result is (_test_text in _test_filter_list) + + await check(Message(text=test_text)) + await check(CallbackQuery(data=test_text)) + await check(InlineQuery(query=test_text)) + await check(Poll(question=test_text)) diff --git a/tests/states_group.py b/tests/test_states_group.py similarity index 100% rename from tests/states_group.py rename to tests/test_states_group.py diff --git a/tests/types/dataset.py b/tests/types/dataset.py index 4167eae1..18bcbdad 100644 --- a/tests/types/dataset.py +++ b/tests/types/dataset.py @@ -8,7 +8,7 @@ USER = { "first_name": "FirstName", "last_name": "LastName", "username": "username", - "language_code": "ru" + "language_code": "ru", } CHAT = { @@ -16,14 +16,14 @@ CHAT = { "first_name": "FirstName", "last_name": "LastName", "username": "username", - "type": "private" + "type": "private", } PHOTO = { "file_id": "AgADBAADFak0G88YZAf8OAug7bHyS9x2ZxkABHVfpJywcloRAAGAAQABAg", "file_size": 1101, "width": 90, - "height": 51 + "height": 51, } AUDIO = { @@ -32,7 +32,7 @@ AUDIO = { "title": "The Best Song", "performer": "The Best Singer", "file_id": "CQADAgADbQEAAsnrIUpNoRRNsH7_hAI", - "file_size": 9507774 + "file_size": 9507774, } CHAT_MEMBER = { @@ -44,7 +44,7 @@ CHAT_MEMBER = { "can_invite_users": True, "can_restrict_members": True, "can_pin_messages": True, - "can_promote_members": False + "can_promote_members": False, } CONTACT = { @@ -57,7 +57,7 @@ DOCUMENT = { "file_name": "test.docx", "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "file_id": "BQADAgADpgADy_JxS66XQTBRHFleAg", - "file_size": 21331 + "file_size": 21331, } ANIMATION = { @@ -65,51 +65,51 @@ ANIMATION = { "mime_type": "video/mp4", "thumb": PHOTO, "file_id": "CgADBAAD4DUAAoceZAe2WiE9y0crrAI", - "file_size": 65837 + "file_size": 65837, } ENTITY_BOLD = { "offset": 5, "length": 2, - "type": "bold" + "type": "bold", } ENTITY_ITALIC = { "offset": 8, "length": 1, - "type": "italic" + "type": "italic", } ENTITY_LINK = { "offset": 10, "length": 6, "type": "text_link", - "url": "http://google.com/" + "url": "http://google.com/", } ENTITY_CODE = { "offset": 17, "length": 7, - "type": "code" + "type": "code", } ENTITY_PRE = { "offset": 30, "length": 4, - "type": "pre" + "type": "pre", } ENTITY_MENTION = { "offset": 47, "length": 9, - "type": "mention" + "type": "mention", } GAME = { "title": "Karate Kido", "description": "No trees were harmed in the making of this game :)", "photo": [PHOTO, PHOTO, PHOTO], - "animation": ANIMATION + "animation": ANIMATION, } INVOICE = { @@ -120,19 +120,19 @@ INVOICE = { "Order our Working Time Machine today!", "start_parameter": "time-machine-example", "currency": "USD", - "total_amount": 6250 + "total_amount": 6250, } LOCATION = { "latitude": 50.693416, - "longitude": 30.624605 + "longitude": 30.624605, } VENUE = { "location": LOCATION, "title": "Venue Name", "address": "Venue Address", - "foursquare_id": "4e6f2cec483bad563d150f98" + "foursquare_id": "4e6f2cec483bad563d150f98", } SHIPPING_ADDRESS = { @@ -141,7 +141,7 @@ SHIPPING_ADDRESS = { "city": "DefaultCity", "street_line1": "Central", "street_line2": "Middle", - "post_code": "424242" + "post_code": "424242", } STICKER = { @@ -156,7 +156,7 @@ STICKER = { "height": 128 }, "file_id": "AAbbCCddEEffGGhh1234567890", - "file_size": 12345 + "file_size": 12345, } SUCCESSFUL_PAYMENT = { @@ -164,7 +164,7 @@ SUCCESSFUL_PAYMENT = { "total_amount": 6250, "invoice_payload": "HAPPY FRIDAYS COUPON", "telegram_payment_charge_id": "_", - "provider_payment_charge_id": "12345678901234_test" + "provider_payment_charge_id": "12345678901234_test", } VIDEO = { @@ -174,7 +174,7 @@ VIDEO = { "mime_type": "video/quicktime", "thumb": PHOTO, "file_id": "BAADAgpAADdawy_JxS72kRvV3cortAg", - "file_size": 10099782 + "file_size": 10099782, } VIDEO_NOTE = { @@ -182,14 +182,14 @@ VIDEO_NOTE = { "length": 240, "thumb": PHOTO, "file_id": "AbCdEfGhIjKlMnOpQrStUvWxYz", - "file_size": 186562 + "file_size": 186562, } VOICE = { "duration": 1, "mime_type": "audio/ogg", "file_id": "AwADawAgADADy_JxS2gopIVIIxlhAg", - "file_size": 4321 + "file_size": 4321, } CALLBACK_QUERY = {} @@ -206,7 +206,7 @@ EDITED_MESSAGE = { "chat": CHAT, "date": 1508825372, "edit_date": 1508825379, - "text": "hi there (edited)" + "text": "hi there (edited)", } FORWARDED_MESSAGE = { @@ -219,7 +219,7 @@ FORWARDED_MESSAGE = { "forward_date": 1522749037, "text": "Forwarded text with entities from public channel ", "entities": [ENTITY_BOLD, ENTITY_CODE, ENTITY_ITALIC, ENTITY_LINK, - ENTITY_LINK, ENTITY_MENTION, ENTITY_PRE] + ENTITY_LINK, ENTITY_MENTION, ENTITY_PRE], } INLINE_QUERY = {} @@ -229,7 +229,7 @@ MESSAGE = { "from": USER, "chat": CHAT, "date": 1508709711, - "text": "Hi, world!" + "text": "Hi, world!", } MESSAGE_WITH_AUDIO = { @@ -238,7 +238,7 @@ MESSAGE_WITH_AUDIO = { "chat": CHAT, "date": 1508739776, "audio": AUDIO, - "caption": "This is my favourite song" + "caption": "This is my favourite song", } MESSAGE_WITH_AUTHOR_SIGNATURE = {} @@ -250,7 +250,7 @@ MESSAGE_WITH_CONTACT = { "from": USER, "chat": CHAT, "date": 1522850298, - "contact": CONTACT + "contact": CONTACT, } MESSAGE_WITH_DELETE_CHAT_PHOTO = {} @@ -261,7 +261,7 @@ MESSAGE_WITH_DOCUMENT = { "chat": CHAT, "date": 1508768012, "document": DOCUMENT, - "caption": "Read my document" + "caption": "Read my document", } MESSAGE_WITH_EDIT_DATE = {} @@ -273,7 +273,7 @@ MESSAGE_WITH_GAME = { "from": USER, "chat": CHAT, "date": 1508824810, - "game": GAME + "game": GAME, } MESSAGE_WITH_GROUP_CHAT_CREATED = {} @@ -283,7 +283,7 @@ MESSAGE_WITH_INVOICE = { "from": USER, "chat": CHAT, "date": 1508761719, - "invoice": INVOICE + "invoice": INVOICE, } MESSAGE_WITH_LEFT_CHAT_MEMBER = {} @@ -293,7 +293,7 @@ MESSAGE_WITH_LOCATION = { "from": USER, "chat": CHAT, "date": 1508755473, - "location": LOCATION + "location": LOCATION, } MESSAGE_WITH_MIGRATE_TO_CHAT_ID = { @@ -301,7 +301,7 @@ MESSAGE_WITH_MIGRATE_TO_CHAT_ID = { "from": USER, "chat": CHAT, "date": 1526943253, - "migrate_to_chat_id": -1234567890987 + "migrate_to_chat_id": -1234567890987, } MESSAGE_WITH_MIGRATE_FROM_CHAT_ID = { @@ -309,7 +309,7 @@ MESSAGE_WITH_MIGRATE_FROM_CHAT_ID = { "from": USER, "chat": CHAT, "date": 1526943253, - "migrate_from_chat_id": -123456789 + "migrate_from_chat_id": -123456789, } MESSAGE_WITH_NEW_CHAT_MEMBERS = {} @@ -324,7 +324,7 @@ MESSAGE_WITH_PHOTO = { "chat": CHAT, "date": 1508825154, "photo": [PHOTO, PHOTO, PHOTO, PHOTO], - "caption": "photo description" + "caption": "photo description", } MESSAGE_WITH_MEDIA_GROUP = { @@ -333,7 +333,7 @@ MESSAGE_WITH_MEDIA_GROUP = { "chat": CHAT, "date": 1522843665, "media_group_id": "12182749320567362", - "photo": [PHOTO, PHOTO, PHOTO, PHOTO] + "photo": [PHOTO, PHOTO, PHOTO, PHOTO], } MESSAGE_WITH_PINNED_MESSAGE = {} @@ -345,7 +345,7 @@ MESSAGE_WITH_STICKER = { "from": USER, "chat": CHAT, "date": 1508771450, - "sticker": STICKER + "sticker": STICKER, } MESSAGE_WITH_SUCCESSFUL_PAYMENT = { @@ -353,7 +353,7 @@ MESSAGE_WITH_SUCCESSFUL_PAYMENT = { "from": USER, "chat": CHAT, "date": 1508761169, - "successful_payment": SUCCESSFUL_PAYMENT + "successful_payment": SUCCESSFUL_PAYMENT, } MESSAGE_WITH_SUPERGROUP_CHAT_CREATED = {} @@ -364,7 +364,7 @@ MESSAGE_WITH_VENUE = { "chat": CHAT, "date": 1522849819, "location": LOCATION, - "venue": VENUE + "venue": VENUE, } MESSAGE_WITH_VIDEO = { @@ -373,7 +373,7 @@ MESSAGE_WITH_VIDEO = { "chat": CHAT, "date": 1508756494, "video": VIDEO, - "caption": "description" + "caption": "description", } MESSAGE_WITH_VIDEO_NOTE = { @@ -381,7 +381,7 @@ MESSAGE_WITH_VIDEO_NOTE = { "from": USER, "chat": CHAT, "date": 1522835890, - "video_note": VIDEO_NOTE + "video_note": VIDEO_NOTE, } MESSAGE_WITH_VOICE = { @@ -389,7 +389,7 @@ MESSAGE_WITH_VOICE = { "from": USER, "chat": CHAT, "date": 1508768403, - "voice": VOICE + "voice": VOICE, } PRE_CHECKOUT_QUERY = { @@ -397,7 +397,7 @@ PRE_CHECKOUT_QUERY = { "from": USER, "currency": "USD", "total_amount": 6250, - "invoice_payload": "HAPPY FRIDAYS COUPON" + "invoice_payload": "HAPPY FRIDAYS COUPON", } REPLY_MESSAGE = { @@ -406,37 +406,37 @@ REPLY_MESSAGE = { "chat": CHAT, "date": 1508751866, "reply_to_message": MESSAGE, - "text": "Reply to quoted message" + "text": "Reply to quoted message", } SHIPPING_QUERY = { "id": "262181558684397422", "from": USER, "invoice_payload": "HAPPY FRIDAYS COUPON", - "shipping_address": SHIPPING_ADDRESS + "shipping_address": SHIPPING_ADDRESS, } USER_PROFILE_PHOTOS = { "total_count": 1, "photos": [ - [PHOTO, PHOTO, PHOTO] - ] + [PHOTO, PHOTO, PHOTO], + ], } FILE = { "file_id": "XXXYYYZZZ", "file_size": 5254, - "file_path": "voice\/file_8" + "file_path": "voice/file_8", } INVITE_LINK = 'https://t.me/joinchat/AbCdEfjKILDADwdd123' UPDATE = { "update_id": 123456789, - "message": MESSAGE + "message": MESSAGE, } WEBHOOK_INFO = { "url": "", "has_custom_certificate": False, - "pending_update_count": 0 + "pending_update_count": 0, } diff --git a/tests/types/test_chat_member.py b/tests/types/test_chat_member.py new file mode 100644 index 00000000..2cea44ce --- /dev/null +++ b/tests/types/test_chat_member.py @@ -0,0 +1,77 @@ +from aiogram import types +from .dataset import CHAT_MEMBER + +chat_member = types.ChatMember(**CHAT_MEMBER) + + +def test_export(): + exported = chat_member.to_python() + assert isinstance(exported, dict) + assert exported == CHAT_MEMBER + + +def test_user(): + assert isinstance(chat_member.user, types.User) + + +def test_status(): + assert isinstance(chat_member.status, str) + assert chat_member.status == CHAT_MEMBER['status'] + + +def test_privileges(): + assert isinstance(chat_member.can_be_edited, bool) + assert chat_member.can_be_edited == CHAT_MEMBER['can_be_edited'] + + assert isinstance(chat_member.can_change_info, bool) + assert chat_member.can_change_info == CHAT_MEMBER['can_change_info'] + + assert isinstance(chat_member.can_delete_messages, bool) + assert chat_member.can_delete_messages == CHAT_MEMBER['can_delete_messages'] + + assert isinstance(chat_member.can_invite_users, bool) + assert chat_member.can_invite_users == CHAT_MEMBER['can_invite_users'] + + assert isinstance(chat_member.can_restrict_members, bool) + assert chat_member.can_restrict_members == CHAT_MEMBER['can_restrict_members'] + + assert isinstance(chat_member.can_pin_messages, bool) + assert chat_member.can_pin_messages == CHAT_MEMBER['can_pin_messages'] + + assert isinstance(chat_member.can_promote_members, bool) + assert chat_member.can_promote_members == CHAT_MEMBER['can_promote_members'] + + +def test_int(): + assert int(chat_member) == chat_member.user.id + assert isinstance(int(chat_member), int) + + +def test_chat_member_status(): + assert types.ChatMemberStatus.CREATOR == 'creator' + assert types.ChatMemberStatus.ADMINISTRATOR == 'administrator' + assert types.ChatMemberStatus.MEMBER == 'member' + assert types.ChatMemberStatus.RESTRICTED == 'restricted' + assert types.ChatMemberStatus.LEFT == 'left' + assert types.ChatMemberStatus.KICKED == 'kicked' + + +def test_chat_member_status_filters(): + assert types.ChatMemberStatus.is_chat_admin(chat_member.status) + assert types.ChatMemberStatus.is_chat_member(chat_member.status) + + assert types.ChatMemberStatus.is_chat_admin(types.ChatMemberStatus.CREATOR) + assert types.ChatMemberStatus.is_chat_admin(types.ChatMemberStatus.ADMINISTRATOR) + + assert types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.CREATOR) + assert types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.ADMINISTRATOR) + assert types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.MEMBER) + assert types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.RESTRICTED) + + assert not types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.LEFT) + assert not types.ChatMemberStatus.is_chat_member(types.ChatMemberStatus.KICKED) + + +def test_chat_member_filters(): + assert chat_member.is_chat_admin() + assert chat_member.is_chat_member()