diff --git a/README.md b/README.md index ae44c524..fca118a0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) @@ -29,9 +29,10 @@ import asyncio from aiogram import Bot +BOT_TOKEN = "" async def main(): - bot = Bot(token=BOT-TOKEN) + bot = Bot(token=BOT_TOKEN) try: me = await bot.get_me() @@ -48,6 +49,8 @@ asyncio.run(main()) import asyncio from aiogram import Bot, Dispatcher, types +BOT_TOKEN = "" + async def start_handler(event: types.Message): await event.answer( f"Hello, {event.from_user.get_mention(as_html=True)} 👋!", @@ -55,7 +58,7 @@ async def start_handler(event: types.Message): ) async def main(): - bot = Bot(token=BOT-TOKEN) + bot = Bot(token=BOT_TOKEN) try: disp = Dispatcher(bot=bot) disp.register_message_handler(start_handler, commands={"start", "restart"}) diff --git a/README.rst b/README.rst index caf6149c..6df651a2 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ AIOGramBot :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 3471ddcd..2d852a7e 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -43,5 +43,5 @@ __all__ = ( 'utils', ) -__version__ = '2.13' -__api_version__ = '5.2' +__version__ = '2.14' +__api_version__ = '5.3' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 38cbee89..1bf00d47 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -189,7 +189,7 @@ class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api - List is updated to Bot API 5.2 + List is updated to Bot API 5.3 """ mode = HelperMode.lowerCamelCase @@ -225,6 +225,7 @@ class Methods(Helper): GET_USER_PROFILE_PHOTOS = Item() # getUserProfilePhotos GET_FILE = Item() # getFile KICK_CHAT_MEMBER = Item() # kickChatMember + BAN_CHAT_MEMBER = Item() # banChatMember UNBAN_CHAT_MEMBER = Item() # unbanChatMember RESTRICT_CHAT_MEMBER = Item() # restrictChatMember PROMOTE_CHAT_MEMBER = Item() # promoteChatMember @@ -244,12 +245,14 @@ class Methods(Helper): LEAVE_CHAT = Item() # leaveChat GET_CHAT = Item() # getChat GET_CHAT_ADMINISTRATORS = Item() # getChatAdministrators - GET_CHAT_MEMBERS_COUNT = Item() # getChatMembersCount + GET_CHAT_MEMBER_COUNT = Item() # getChatMemberCount + GET_CHAT_MEMBERS_COUNT = Item() # getChatMembersCount (renamed to getChatMemberCount) GET_CHAT_MEMBER = Item() # getChatMember SET_CHAT_STICKER_SET = Item() # setChatStickerSet DELETE_CHAT_STICKER_SET = Item() # deleteChatStickerSet ANSWER_CALLBACK_QUERY = Item() # answerCallbackQuery SET_MY_COMMANDS = Item() # setMyCommands + DELETE_MY_COMMANDS = Item() # deleteMyCommands GET_MY_COMMANDS = Item() # getMyCommands # Updating messages diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 07d4b963..435def3e 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1562,41 +1562,42 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.GET_FILE, payload) return types.File(**result) - async def kick_chat_member(self, - chat_id: typing.Union[base.Integer, base.String], - user_id: base.Integer, - until_date: typing.Union[base.Integer, datetime.datetime, - datetime.timedelta, None] = None, - revoke_messages: typing.Optional[base.Boolean] = None, - ) -> base.Boolean: + async def ban_chat_member(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + until_date: typing.Union[base.Integer, datetime.datetime, + datetime.timedelta, None] = None, + revoke_messages: typing.Optional[base.Boolean] = None, + ) -> base.Boolean: """ - Use this method to kick a user from a group, a supergroup or a channel. - In the case of supergroups and channels, the user will not be able to return - to the chat on their own using invite links, etc., unless unbanned first. + Use this method to ban a user in a group, a supergroup or a + channel. In the case of supergroups and channels, the user will + not be able to return to the chat on their own using invite + links, etc., unless unbanned first. The bot must be an + administrator in the chat for this to work and must have the + appropriate admin rights. Returns True on success. - The bot must be an administrator in the chat for this to work and must have - the appropriate admin rights. + Source: https://core.telegram.org/bots/api#banchatmember - Source: https://core.telegram.org/bots/api#kickchatmember - - :param chat_id: Unique identifier for the target group or username of the - target supergroup or channel (in the format @channelusername) + :param chat_id: Unique identifier for the target group or + username of the target supergroup or channel (in the format + @channelusername) :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :param until_date: Date when the user will be unbanned. If user is banned - for more than 366 days or less than 30 seconds from the current time they - are considered to be banned forever. Applied for supergroups and channels - only. - :type until_date: :obj:`typing.Union[base.Integer, datetime.datetime, - datetime.timedelta, None]` + :param until_date: Date when the user will be unbanned, unix + time. If user is banned for more than 366 days or less than + 30 seconds from the current time they are considered to be + banned forever. Applied for supergroups and channels only. + :type until_date: :obj:`typing.Union[base.Integer, + datetime.datetime, datetime.timedelta, None]` - :param revoke_messages: Pass True to delete all messages from the chat for - the user that is being removed. If False, the user will be able to see - messages in the group that were sent before the user was removed. Always - True for supergroups and channels. + :param revoke_messages: Pass True to delete all messages from + the chat for the user that is being removed. If False, the user + will be able to see messages in the group that were sent before + the user was removed. Always True for supergroups and channels. :type revoke_messages: :obj:`typing.Optional[base.Boolean]` :return: Returns True on success @@ -1605,7 +1606,22 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): until_date = prepare_arg(until_date) payload = generate_payload(**locals()) - return await self.request(api.Methods.KICK_CHAT_MEMBER, payload) + return await self.request(api.Methods.BAN_CHAT_MEMBER, payload) + + async def kick_chat_member(self, + chat_id: typing.Union[base.Integer, base.String], + user_id: base.Integer, + until_date: typing.Union[base.Integer, datetime.datetime, + datetime.timedelta, None] = None, + revoke_messages: typing.Optional[base.Boolean] = None, + ) -> base.Boolean: + """Renamed to ban_chat_member.""" + return await self.ban_chat_member( + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + revoke_messages=revoke_messages, + ) async def unban_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -2130,13 +2146,13 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_CHAT_ADMINISTRATORS, payload) - return [types.ChatMember(**chatmember) for chatmember in result] + return [types.ChatMember.resolve(**chat_member) for chat_member in result] - async def get_chat_members_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: + async def get_chat_member_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: """ Use this method to get the number of members in a chat. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` @@ -2145,7 +2161,11 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): """ payload = generate_payload(**locals()) - return await self.request(api.Methods.GET_CHAT_MEMBERS_COUNT, payload) + return await self.request(api.Methods.GET_CHAT_MEMBER_COUNT, payload) + + async def get_chat_members_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: + """Renamed to get_chat_member_count.""" + return await self.get_chat_member_count(chat_id) async def get_chat_member(self, chat_id: typing.Union[base.Integer, base.String], user_id: base.Integer) -> types.ChatMember: @@ -2164,7 +2184,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_CHAT_MEMBER, payload) - return types.ChatMember(**result) + return types.ChatMember.resolve(**result) async def set_chat_sticker_set(self, chat_id: typing.Union[base.Integer, base.String], sticker_set_name: base.String) -> base.Boolean: @@ -2241,31 +2261,95 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return await self.request(api.Methods.ANSWER_CALLBACK_QUERY, payload) - async def set_my_commands(self, commands: typing.List[types.BotCommand]) -> base.Boolean: + async def set_my_commands(self, + commands: typing.List[types.BotCommand], + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> base.Boolean: """ Use this method to change the list of the bot's commands. Source: https://core.telegram.org/bots/api#setmycommands - :param commands: A JSON-serialized list of bot commands to be set as the list of the bot's commands. - At most 100 commands can be specified. + :param commands: A JSON-serialized list of bot commands to be + set as the list of the bot's commands. At most 100 commands + can be specified. :type commands: :obj: `typing.List[types.BotCommand]` + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + :return: Returns True on success. :rtype: :obj:`base.Boolean` """ commands = prepare_arg(commands) + scope = prepare_arg(scope) payload = generate_payload(**locals()) return await self.request(api.Methods.SET_MY_COMMANDS, payload) - async def get_my_commands(self) -> typing.List[types.BotCommand]: + async def delete_my_commands(self, + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> base.Boolean: """ - Use this method to get the current list of the bot's commands. + Use this method to delete the list of the bot's commands for the + given scope and user language. After deletion, higher level + commands will be shown to affected users. + + Source: https://core.telegram.org/bots/api#deletemycommands + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + + :return: Returns True on success. + :rtype: :obj:`base.Boolean` + """ + scope = prepare_arg(scope) + payload = generate_payload(**locals()) + + return await self.request(api.Methods.DELETE_MY_COMMANDS, payload) + + async def get_my_commands(self, + scope: typing.Optional[types.BotCommandScope] = None, + language_code: typing.Optional[base.String] = None, + ) -> typing.List[types.BotCommand]: + """ + Use this method to get the current list of the bot's commands + for the given scope and user language. Returns Array of + BotCommand on success. If commands aren't set, an empty list is + returned. Source: https://core.telegram.org/bots/api#getmycommands - :return: Returns Array of BotCommand on success. + + :param scope: A JSON-serialized object, describing scope of + users for which the commands are relevant. Defaults to + BotCommandScopeDefault. + :type scope: :obj: `typing.Optional[types.BotCommandScope]` + + :param language_code: A two-letter ISO 639-1 language code. If + empty, commands will be applied to all users from the given + scope, for whose language there are no dedicated commands + :type language_code: :obj: `typing.Optional[base.String]` + + :return: Returns Array of BotCommand on success or empty list. :rtype: :obj:`typing.List[types.BotCommand]` """ + scope = prepare_arg(scope) payload = generate_payload(**locals()) result = await self.request(api.Methods.GET_MY_COMMANDS, payload) diff --git a/aiogram/contrib/fsm_storage/memory.py b/aiogram/contrib/fsm_storage/memory.py index e1d6bdc0..8950aa8e 100644 --- a/aiogram/contrib/fsm_storage/memory.py +++ b/aiogram/contrib/fsm_storage/memory.py @@ -66,6 +66,7 @@ class MemoryStorage(BaseStorage): data: typing.Dict = None): chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['data'] = copy.deepcopy(data) + self._cleanup(chat, user) async def reset_state(self, *, chat: typing.Union[str, int, None] = None, @@ -74,6 +75,7 @@ class MemoryStorage(BaseStorage): await self.set_state(chat=chat, user=user, state=None) if with_data: await self.set_data(chat=chat, user=user, data={}) + self._cleanup(chat, user) def has_bucket(self): return True @@ -91,6 +93,7 @@ class MemoryStorage(BaseStorage): bucket: typing.Dict = None): chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['bucket'] = copy.deepcopy(bucket) + self._cleanup(chat, user) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, @@ -100,3 +103,9 @@ class MemoryStorage(BaseStorage): bucket = {} chat, user = self.resolve_address(chat=chat, user=user) self.data[chat][user]['bucket'].update(bucket, **kwargs) + + def _cleanup(self, chat, user): + if self.data[chat][user] == {'state': None, 'data': {}, 'bucket': {}}: + del self.data[chat][user] + if not self.data[chat]: + del self.data[chat] diff --git a/aiogram/contrib/fsm_storage/mongo.py b/aiogram/contrib/fsm_storage/mongo.py index 992e2e70..ab7d3176 100644 --- a/aiogram/contrib/fsm_storage/mongo.py +++ b/aiogram/contrib/fsm_storage/mongo.py @@ -142,9 +142,11 @@ class MongoStorage(BaseStorage): data: Dict = None): chat, user = self.check_address(chat=chat, user=user) db = await self.get_db() - - await db[DATA].update_one(filter={'chat': chat, 'user': user}, - update={'$set': {'data': data}}, upsert=True) + if not data: + await db[DATA].delete_one(filter={'chat': chat, 'user': user}) + else: + await db[DATA].update_one(filter={'chat': chat, 'user': user}, + update={'$set': {'data': data}}, upsert=True) async def get_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None, default: Optional[dict] = None) -> Dict: diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 01a0fe5c..5d0b762c 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -110,10 +110,12 @@ class RedisStorage(BaseStorage): chat, user = self.check_address(chat=chat, user=user) addr = f"fsm:{chat}:{user}" - record = {'state': state, 'data': data, 'bucket': bucket} - conn = await self.redis() - await conn.execute('SET', addr, json.dumps(record)) + if state is None and data == bucket == {}: + await conn.execute('DEL', addr) + else: + record = {'state': state, 'data': data, 'bucket': bucket} + await conn.execute('SET', addr, json.dumps(record)) async def get_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, default: typing.Optional[str] = None) -> typing.Optional[str]: @@ -222,7 +224,7 @@ class RedisStorage2(BaseStorage): await dp.storage.wait_closed() """ - def __init__(self, host: str = 'localhost', port=6379, db=None, password=None, + def __init__(self, host: str = 'localhost', port=6379, db=None, password=None, ssl=None, pool_size=10, loop=None, prefix='fsm', state_ttl: int = 0, data_ttl: int = 0, @@ -304,7 +306,10 @@ class RedisStorage2(BaseStorage): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_DATA_KEY) redis = await self.redis() - await redis.set(key, json.dumps(data), expire=self._data_ttl) + if data: + await redis.set(key, json.dumps(data), expire=self._data_ttl) + else: + await redis.delete(key) async def update_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None, **kwargs): @@ -332,7 +337,10 @@ class RedisStorage2(BaseStorage): chat, user = self.check_address(chat=chat, user=user) key = self.generate_key(chat, user, STATE_BUCKET_KEY) redis = await self.redis() - await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) + if bucket: + await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) + else: + await redis.delete(key) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 90909e81..a9e6af8c 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -4,6 +4,10 @@ from .animation import Animation from .audio import Audio from .auth_widget_data import AuthWidgetData from .bot_command import BotCommand +from .bot_command_scope import BotCommandScope, BotCommandScopeAllChatAdministrators, \ + BotCommandScopeAllGroupChats, BotCommandScopeAllPrivateChats, BotCommandScopeChat, \ + BotCommandScopeChatAdministrators, BotCommandScopeChatMember, \ + BotCommandScopeDefault, BotCommandScopeType from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat, ChatActions, ChatType @@ -82,6 +86,15 @@ __all__ = ( 'Audio', 'AuthWidgetData', 'BotCommand', + 'BotCommandScope', + 'BotCommandScopeAllChatAdministrators', + 'BotCommandScopeAllGroupChats', + 'BotCommandScopeAllPrivateChats', + 'BotCommandScopeChat', + 'BotCommandScopeChatAdministrators', + 'BotCommandScopeChatMember', + 'BotCommandScopeDefault', + 'BotCommandScopeType', 'CallbackGame', 'CallbackQuery', 'Chat', diff --git a/aiogram/types/bot_command_scope.py b/aiogram/types/bot_command_scope.py new file mode 100644 index 00000000..e3091a7e --- /dev/null +++ b/aiogram/types/bot_command_scope.py @@ -0,0 +1,121 @@ +import typing + +from . import base, fields +from ..utils import helper + + +class BotCommandScopeType(helper.Helper): + mode = helper.HelperMode.lowercase + + DEFAULT = helper.Item() # default + ALL_PRIVATE_CHATS = helper.Item() # all_private_chats + ALL_GROUP_CHATS = helper.Item() # all_group_chats + ALL_CHAT_ADMINISTRATORS = helper.Item() # all_chat_administrators + CHAT = helper.Item() # chat + CHAT_ADMINISTRATORS = helper.Item() # chat_administrators + CHAT_MEMBER = helper.Item() # chat_member + + +class BotCommandScope(base.TelegramObject): + """ + This object represents the scope to which bot commands are applied. + Currently, the following 7 scopes are supported: + BotCommandScopeDefault + BotCommandScopeAllPrivateChats + BotCommandScopeAllGroupChats + BotCommandScopeAllChatAdministrators + BotCommandScopeChat + BotCommandScopeChatAdministrators + BotCommandScopeChatMember + + https://core.telegram.org/bots/api#botcommandscope + """ + type: base.String = fields.Field() + + @classmethod + def from_type(cls, type: str, **kwargs: typing.Any): + if type == BotCommandScopeType.DEFAULT: + return BotCommandScopeDefault(type=type, **kwargs) + if type == BotCommandScopeType.ALL_PRIVATE_CHATS: + return BotCommandScopeAllPrivateChats(type=type, **kwargs) + if type == BotCommandScopeType.ALL_GROUP_CHATS: + return BotCommandScopeAllGroupChats(type=type, **kwargs) + if type == BotCommandScopeType.ALL_CHAT_ADMINISTRATORS: + return BotCommandScopeAllChatAdministrators(type=type, **kwargs) + if type == BotCommandScopeType.CHAT: + return BotCommandScopeChat(type=type, **kwargs) + if type == BotCommandScopeType.CHAT_ADMINISTRATORS: + return BotCommandScopeChatAdministrators(type=type, **kwargs) + if type == BotCommandScopeType.CHAT_MEMBER: + return BotCommandScopeChatMember(type=type, **kwargs) + raise ValueError(f"Unknown BotCommandScope type {type!r}") + + +class BotCommandScopeDefault(BotCommandScope): + """ + Represents the default scope of bot commands. + Default commands are used if no commands with a narrower scope are + specified for the user. + """ + type = fields.Field(default=BotCommandScopeType.DEFAULT) + + +class BotCommandScopeAllPrivateChats(BotCommandScope): + """ + Represents the scope of bot commands, covering all private chats. + """ + type = fields.Field(default=BotCommandScopeType.ALL_PRIVATE_CHATS) + + +class BotCommandScopeAllGroupChats(BotCommandScope): + """ + Represents the scope of bot commands, covering all group and + supergroup chats. + """ + type = fields.Field(default=BotCommandScopeType.ALL_GROUP_CHATS) + + +class BotCommandScopeAllChatAdministrators(BotCommandScope): + """ + Represents the scope of bot commands, covering all group and + supergroup chat administrators. + """ + type = fields.Field(default=BotCommandScopeType.ALL_CHAT_ADMINISTRATORS) + + +class BotCommandScopeChat(BotCommandScope): + """ + Represents the scope of bot commands, covering a specific chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + + def __init__(self, chat_id: typing.Union[base.String, base.Integer], **kwargs): + super().__init__(chat_id=chat_id, **kwargs) + + +class BotCommandScopeChatAdministrators(BotCommandScopeChat): + """ + Represents the scope of bot commands, covering all administrators + of a specific group or supergroup chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT_ADMINISTRATORS) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + + +class BotCommandScopeChatMember(BotCommandScopeChat): + """ + Represents the scope of bot commands, covering a specific member of + a group or supergroup chat. + """ + type = fields.Field(default=BotCommandScopeType.CHAT_MEMBER) + chat_id: typing.Union[base.String, base.Integer] = fields.Field() + user_id: base.Integer = fields.Field() + + def __init__( + self, + chat_id: typing.Union[base.String, base.Integer], + user_id: base.Integer, + **kwargs, + ): + super().__init__(chat_id=chat_id, user_id=user_id, **kwargs) diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 5b3b315a..2cd19a0f 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -301,7 +301,7 @@ class Chat(base.TelegramObject): can_send_other_messages=can_send_other_messages, can_add_web_page_previews=can_add_web_page_previews) - async def promote(self, + async def promote(self, user_id: base.Integer, is_anonymous: typing.Optional[base.Boolean] = None, can_change_info: typing.Optional[base.Boolean] = None, @@ -321,36 +321,36 @@ class Chat(base.TelegramObject): :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - + :param is_anonymous: Pass True, if the administrator's presence in the chat is hidden :type is_anonymous: :obj:`typing.Optional[base.Boolean]` - + :param can_change_info: Pass True, if the administrator can change chat title, photo and other settings :type can_change_info: :obj:`typing.Optional[base.Boolean]` - + :param can_post_messages: Pass True, if the administrator can create channel posts, channels only :type can_post_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_edit_messages: Pass True, if the administrator can edit messages of other users, channels only :type can_edit_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_delete_messages: Pass True, if the administrator can delete messages of other users :type can_delete_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_invite_users: Pass True, if the administrator can invite new users to the chat :type can_invite_users: :obj:`typing.Optional[base.Boolean]` - + :param can_restrict_members: Pass True, if the administrator can restrict, ban or unban chat members :type can_restrict_members: :obj:`typing.Optional[base.Boolean]` - + :param can_pin_messages: Pass True, if the administrator can pin messages, supergroups only :type can_pin_messages: :obj:`typing.Optional[base.Boolean]` - + :param can_promote_members: Pass True, if the administrator can add new administrators with a subset of his own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by him) :type can_promote_members: :obj:`typing.Optional[base.Boolean]` - + :return: Returns True on success. :rtype: :obj:`base.Boolean` """ @@ -484,16 +484,20 @@ class Chat(base.TelegramObject): """ return await self.bot.get_chat_administrators(self.id) - async def get_members_count(self) -> base.Integer: + async def get_member_count(self) -> base.Integer: """ Use this method to get the number of members in a chat. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount :return: Returns Int on success. :rtype: :obj:`base.Integer` """ - return await self.bot.get_chat_members_count(self.id) + return await self.bot.get_chat_member_count(self.id) + + async def get_members_count(self) -> base.Integer: + """Renamed to get_member_count.""" + return await self.get_member_count(self.id) async def get_member(self, user_id: base.Integer) -> ChatMember: """ diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index c48a91d0..58e4cb62 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,53 +1,11 @@ import datetime +from typing import Optional -from . import base -from . import fields +from . import base, fields from .user import User from ..utils import helper -class ChatMember(base.TelegramObject): - """ - This object contains information about one member of a chat. - - https://core.telegram.org/bots/api#chatmember - """ - user: User = fields.Field(base=User) - status: base.String = fields.Field() - custom_title: base.String = fields.Field() - is_anonymous: base.Boolean = fields.Field() - can_be_edited: base.Boolean = fields.Field() - can_manage_chat: base.Boolean = fields.Field() - can_post_messages: base.Boolean = fields.Field() - can_edit_messages: base.Boolean = fields.Field() - can_delete_messages: base.Boolean = fields.Field() - can_manage_voice_chats: base.Boolean = fields.Field() - can_restrict_members: base.Boolean = fields.Field() - can_promote_members: base.Boolean = fields.Field() - can_change_info: base.Boolean = fields.Field() - can_invite_users: base.Boolean = fields.Field() - can_pin_messages: base.Boolean = fields.Field() - is_member: base.Boolean = fields.Field() - can_send_messages: base.Boolean = fields.Field() - can_send_media_messages: base.Boolean = fields.Field() - can_send_polls: base.Boolean = fields.Field() - can_send_other_messages: base.Boolean = fields.Field() - can_add_web_page_previews: base.Boolean = fields.Field() - until_date: datetime.datetime = fields.DateTimeField() - - def is_chat_creator(self) -> bool: - return ChatMemberStatus.is_chat_creator(self.status) - - def is_chat_admin(self) -> bool: - return ChatMemberStatus.is_chat_admin(self.status) - - def is_chat_member(self) -> bool: - return ChatMemberStatus.is_chat_member(self.status) - - def __int__(self) -> int: - return self.user.id - - class ChatMemberStatus(helper.Helper): """ Chat member status @@ -55,11 +13,13 @@ class ChatMemberStatus(helper.Helper): mode = helper.HelperMode.lowercase CREATOR = helper.Item() # creator + OWNER = CREATOR # creator ADMINISTRATOR = helper.Item() # administrator MEMBER = helper.Item() # member RESTRICTED = helper.Item() # restricted LEFT = helper.Item() # left KICKED = helper.Item() # kicked + BANNED = KICKED # kicked @classmethod def is_chat_creator(cls, role: str) -> bool: @@ -72,3 +32,141 @@ class ChatMemberStatus(helper.Helper): @classmethod def is_chat_member(cls, role: str) -> bool: return role in (cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR, cls.RESTRICTED) + + @classmethod + def get_class_by_status(cls, status: str) -> Optional["ChatMember"]: + return { + cls.OWNER: ChatMemberOwner, + cls.ADMINISTRATOR: ChatMemberAdministrator, + cls.MEMBER: ChatMemberMember, + cls.RESTRICTED: ChatMemberRestricted, + cls.LEFT: ChatMemberLeft, + cls.BANNED: ChatMemberBanned, + }.get(status) + + +class ChatMember(base.TelegramObject): + """ + This object contains information about one member of a chat. + Currently, the following 6 types of chat members are supported: + ChatMemberOwner + ChatMemberAdministrator + ChatMemberMember + ChatMemberRestricted + ChatMemberLeft + ChatMemberBanned + + https://core.telegram.org/bots/api#chatmember + """ + status: base.String = fields.Field() + user: User = fields.Field(base=User) + + def __int__(self) -> int: + return self.user.id + + @classmethod + def resolve(cls, **kwargs) -> "ChatMember": + status = kwargs.get("status") + mapping = { + ChatMemberStatus.OWNER: ChatMemberOwner, + ChatMemberStatus.ADMINISTRATOR: ChatMemberAdministrator, + ChatMemberStatus.MEMBER: ChatMemberMember, + ChatMemberStatus.RESTRICTED: ChatMemberRestricted, + ChatMemberStatus.LEFT: ChatMemberLeft, + ChatMemberStatus.BANNED: ChatMemberBanned, + } + class_ = mapping.get(status) + if class_ is None: + raise ValueError(f"Can't find `ChatMember` class for status `{status}`") + + return class_(**kwargs) + + +class ChatMemberOwner(ChatMember): + """ + Represents a chat member that owns the chat and has all + administrator privileges. + https://core.telegram.org/bots/api#chatmemberowner + """ + status: base.String = fields.Field(default=ChatMemberStatus.OWNER) + user: User = fields.Field(base=User) + custom_title: base.String = fields.Field() + is_anonymous: base.Boolean = fields.Field() + + +class ChatMemberAdministrator(ChatMember): + """ + Represents a chat member that has some additional privileges. + + https://core.telegram.org/bots/api#chatmemberadministrator + """ + status: base.String = fields.Field(default=ChatMemberStatus.ADMINISTRATOR) + user: User = fields.Field(base=User) + can_be_edited: base.Boolean = fields.Field() + custom_title: base.String = fields.Field() + is_anonymous: base.Boolean = fields.Field() + can_manage_chat: base.Boolean = fields.Field() + can_post_messages: base.Boolean = fields.Field() + can_edit_messages: base.Boolean = fields.Field() + can_delete_messages: base.Boolean = fields.Field() + can_manage_voice_chats: base.Boolean = fields.Field() + can_restrict_members: base.Boolean = fields.Field() + can_promote_members: base.Boolean = fields.Field() + can_change_info: base.Boolean = fields.Field() + can_invite_users: base.Boolean = fields.Field() + can_pin_messages: base.Boolean = fields.Field() + + +class ChatMemberMember(ChatMember): + """ + Represents a chat member that has no additional privileges or + restrictions. + + https://core.telegram.org/bots/api#chatmembermember + """ + status: base.String = fields.Field(default=ChatMemberStatus.MEMBER) + user: User = fields.Field(base=User) + + +class ChatMemberRestricted(ChatMember): + """ + Represents a chat member that is under certain restrictions in the + chat. Supergroups only. + + https://core.telegram.org/bots/api#chatmemberrestricted + """ + status: base.String = fields.Field(default=ChatMemberStatus.RESTRICTED) + user: User = fields.Field(base=User) + is_member: base.Boolean = fields.Field() + can_change_info: base.Boolean = fields.Field() + can_invite_users: base.Boolean = fields.Field() + can_pin_messages: base.Boolean = fields.Field() + can_send_messages: base.Boolean = fields.Field() + can_send_media_messages: base.Boolean = fields.Field() + can_send_polls: base.Boolean = fields.Field() + can_send_other_messages: base.Boolean = fields.Field() + can_add_web_page_previews: base.Boolean = fields.Field() + until_date: datetime.datetime = fields.DateTimeField() + + +class ChatMemberLeft(ChatMember): + """ + Represents a chat member that isn't currently a member of the chat, + but may join it themselves. + + https://core.telegram.org/bots/api#chatmemberleft + """ + status: base.String = fields.Field(default=ChatMemberStatus.LEFT) + user: User = fields.Field(base=User) + + +class ChatMemberBanned(ChatMember): + """ + Represents a chat member that was banned in the chat and can't + return to the chat or view chat messages. + + https://core.telegram.org/bots/api#chatmemberbanned + """ + status: base.String = fields.Field(default=ChatMemberStatus.BANNED) + user: User = fields.Field(base=User) + until_date: datetime.datetime = fields.DateTimeField() diff --git a/aiogram/types/force_reply.py b/aiogram/types/force_reply.py index 97ec16c6..d6b4f19f 100644 --- a/aiogram/types/force_reply.py +++ b/aiogram/types/force_reply.py @@ -6,31 +6,28 @@ from . import fields class ForceReply(base.TelegramObject): """ - Upon receiving a message with this object, - Telegram clients will display a reply interface to the user - (act as if the user has selected the bot‘s message and tapped ’Reply'). - This can be extremely useful if you want to create user-friendly step-by-step + Upon receiving a message with this object, Telegram clients will + display a reply interface to the user (act as if the user has + selected the bot's message and tapped 'Reply'). This can be + extremely useful if you want to create user-friendly step-by-step interfaces without having to sacrifice privacy mode. - Example: A poll bot for groups runs in privacy mode - (only receives commands, replies to its messages and mentions). - There could be two ways to create a new poll - - The last option is definitely more attractive. - And if you use ForceReply in your bot‘s questions, it will receive the user’s answers even - if it only receives replies, commands and mentions — without any extra work for the user. - https://core.telegram.org/bots/api#forcereply """ force_reply: base.Boolean = fields.Field(default=True) + input_field_placeholder: base.String = fields.Field() selective: base.Boolean = fields.Field() @classmethod - def create(cls, selective: typing.Optional[base.Boolean] = None): + def create(cls, + input_field_placeholder: typing.Optional[base.String] = None, + selective: typing.Optional[base.Boolean] = None, + ) -> 'ForceReply': """ Create new force reply :param selective: + :param input_field_placeholder: :return: """ - return cls(selective=selective) + return cls(selective=selective, input_field_placeholder=input_field_placeholder) diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index 4d03daec..6804b460 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -28,6 +28,7 @@ class InputMedia(base.TelegramObject): thumb: typing.Union[base.InputFile, base.String] = fields.Field(alias='thumb', on_change='_thumb_changed') caption: base.String = fields.Field() parse_mode: base.String = fields.Field() + caption_entities: typing.List[MessageEntity] = fields.ListField(base=MessageEntity) def __init__(self, *args, **kwargs): self._thumb_file = None diff --git a/aiogram/types/message.py b/aiogram/types/message.py index ce8395d2..c95b14b1 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -194,7 +194,8 @@ class Message(base.TelegramObject): :return: bool """ - return self.text and self.text.startswith("/") + text = self.text or self.caption + return text and text.startswith("/") def get_full_command(self) -> typing.Optional[typing.Tuple[str, str]]: """ @@ -203,8 +204,9 @@ class Message(base.TelegramObject): :return: tuple of (command, args) """ if self.is_command(): - command, *args = self.text.split(maxsplit=1) - args = args[-1] if args else "" + text = self.text or self.caption + command, *args = text.split(maxsplit=1) + args = args[0] if args else "" return command, args def get_command(self, pure=False) -> typing.Optional[str]: @@ -271,7 +273,7 @@ class Message(base.TelegramObject): :return: str """ - + if self.chat.type == ChatType.PRIVATE: raise TypeError("Invalid chat type!") url = "https://t.me/" @@ -1420,7 +1422,7 @@ class Message(base.TelegramObject): allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) - + async def answer_chat_action( self, action: base.String, @@ -2931,7 +2933,7 @@ class Message(base.TelegramObject): question=self.poll.question, options=[option.text for option in self.poll.options], is_anonymous=self.poll.is_anonymous, - allows_multiple_answers=self.poll.allows_multiple_answers + allows_multiple_answers=self.poll.allows_multiple_answers, **kwargs, ) elif self.dice: diff --git a/aiogram/types/reply_keyboard.py b/aiogram/types/reply_keyboard.py index ffe07ae1..e648e036 100644 --- a/aiogram/types/reply_keyboard.py +++ b/aiogram/types/reply_keyboard.py @@ -18,23 +18,32 @@ class KeyboardButtonPollType(base.TelegramObject): class ReplyKeyboardMarkup(base.TelegramObject): """ - This object represents a custom keyboard with reply options (see Introduction to bots for details and examples). + This object represents a custom keyboard with reply options + (see https://core.telegram.org/bots#keyboards to bots for details + and examples). https://core.telegram.org/bots/api#replykeyboardmarkup """ keyboard: 'typing.List[typing.List[KeyboardButton]]' = fields.ListOfLists(base='KeyboardButton', default=[]) resize_keyboard: base.Boolean = fields.Field() one_time_keyboard: base.Boolean = fields.Field() + input_field_placeholder: base.String = fields.Field() selective: base.Boolean = fields.Field() def __init__(self, keyboard: 'typing.List[typing.List[KeyboardButton]]' = None, resize_keyboard: base.Boolean = None, one_time_keyboard: base.Boolean = None, + input_field_placeholder: base.String = None, selective: base.Boolean = None, row_width: base.Integer = 3): - super(ReplyKeyboardMarkup, self).__init__(keyboard=keyboard, resize_keyboard=resize_keyboard, - one_time_keyboard=one_time_keyboard, selective=selective, - conf={'row_width': row_width}) + super().__init__( + keyboard=keyboard, + resize_keyboard=resize_keyboard, + one_time_keyboard=one_time_keyboard, + input_field_placeholder=input_field_placeholder, + selective=selective, + conf={'row_width': row_width}, + ) @property def row_width(self): diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 7cf616bb..e2fd3a55 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -1,7 +1,5 @@ from __future__ import annotations -from functools import lru_cache - from . import base from . import fields from .callback_query import CallbackQuery @@ -76,7 +74,5 @@ class AllowedUpdates(helper.Helper): ) @classmethod - @lru_cache(1) def default(cls): - excluded = cls.CHAT_MEMBER + cls.MY_CHAT_MEMBER - return list(filter(lambda item: item not in excluded, cls.all())) + return [] diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index 9a1606a6..e3a1f313 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -368,7 +368,7 @@ class WrongFileIdentifier(BadRequest): class GroupDeactivated(BadRequest): - match = 'group is deactivated' + match = 'Group chat was deactivated' class PhotoAsInputFileRequired(BadRequest): diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 09484e3c..40fe296b 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -185,7 +185,7 @@ class MarkdownDecoration(TextDecoration): return f"`{value}`" def pre(self, value: str) -> str: - return f"```{value}```" + return f"```\n{value}\n```" def pre_language(self, value: str, language: str) -> str: return f"```{language}\n{value}\n```" diff --git a/dev_requirements.txt b/dev_requirements.txt index ef5272af..26e410aa 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -16,3 +16,4 @@ aiohttp-socks>=0.3.4 rethinkdb>=2.4.1 coverage==4.5.3 motor>=2.2.0 +pytest-lazy-fixture==0.6.* diff --git a/docs/source/index.rst b/docs/source/index.rst index 3631b150..cd4b99d0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,7 +22,7 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/tests/contrib/fsm_storage/test_redis.py b/tests/contrib/fsm_storage/test_redis.py deleted file mode 100644 index 527c905e..00000000 --- a/tests/contrib/fsm_storage/test_redis.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from aiogram.contrib.fsm_storage.redis import RedisStorage2 - - -@pytest.fixture() -async def store(redis_options): - s = RedisStorage2(**redis_options) - try: - yield s - finally: - conn = await s.redis() - await conn.flushdb() - await s.close() - await s.wait_closed() - - -@pytest.mark.redis -class TestRedisStorage2: - @pytest.mark.asyncio - async def test_set_get(self, store): - assert await store.get_data(chat='1234') == {} - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - - @pytest.mark.asyncio - async def test_close_and_open_connection(self, store): - await store.set_data(chat='1234', data={'foo': 'bar'}) - assert await store.get_data(chat='1234') == {'foo': 'bar'} - pool_id = id(store._redis) - await store.close() - assert await store.get_data(chat='1234') == {'foo': 'bar'} # new pool was opened at this point - assert id(store._redis) != pool_id diff --git a/tests/contrib/fsm_storage/test_storage.py b/tests/contrib/fsm_storage/test_storage.py new file mode 100644 index 00000000..0cde2de2 --- /dev/null +++ b/tests/contrib/fsm_storage/test_storage.py @@ -0,0 +1,79 @@ +import pytest + +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.contrib.fsm_storage.redis import RedisStorage2, RedisStorage + + +@pytest.fixture() +@pytest.mark.redis +async def redis_store(redis_options): + s = RedisStorage(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.execute('FLUSHDB') + await s.close() + await s.wait_closed() + + +@pytest.fixture() +@pytest.mark.redis +async def redis_store2(redis_options): + s = RedisStorage2(**redis_options) + try: + yield s + finally: + conn = await s.redis() + await conn.flushdb() + await s.close() + await s.wait_closed() + + +@pytest.fixture() +async def memory_store(): + yield MemoryStorage() + + +@pytest.mark.parametrize( + "store", [ + pytest.lazy_fixture('redis_store'), + pytest.lazy_fixture('redis_store2'), + pytest.lazy_fixture('memory_store'), + ] +) +class TestStorage: + @pytest.mark.asyncio + async def test_set_get(self, store): + assert await store.get_data(chat='1234') == {} + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + + @pytest.mark.asyncio + async def test_reset(self, store): + await store.set_data(chat='1234', data={'foo': 'bar'}) + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + @pytest.mark.asyncio + async def test_reset_empty(self, store): + await store.reset_data(chat='1234') + assert await store.get_data(chat='1234') == {} + + +@pytest.mark.parametrize( + "store", [ + pytest.lazy_fixture('redis_store'), + pytest.lazy_fixture('redis_store2'), + ] +) +class TestRedisStorage2: + @pytest.mark.asyncio + async def test_close_and_open_connection(self, store): + await store.set_data(chat='1234', data={'foo': 'bar'}) + assert await store.get_data(chat='1234') == {'foo': 'bar'} + pool_id = id(store._redis) + await store.close() + assert await store.get_data(chat='1234') == { + 'foo': 'bar'} # new pool was opened at this point + assert id(store._redis) != pool_id diff --git a/tests/test_bot.py b/tests/test_bot.py index 224666ec..61abe962 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -427,7 +427,7 @@ async def test_get_chat_administrators(bot: Bot): """ getChatAdministrators method test """ from .types.dataset import CHAT, CHAT_MEMBER chat = types.Chat(**CHAT) - member = types.ChatMember(**CHAT_MEMBER) + member = types.ChatMember.resolve(**CHAT_MEMBER) async with FakeTelegram(message_data=[CHAT_MEMBER, CHAT_MEMBER]): result = await bot.get_chat_administrators(chat_id=chat.id) @@ -435,14 +435,14 @@ async def test_get_chat_administrators(bot: Bot): assert len(result) == 2 -async def test_get_chat_members_count(bot: Bot): +async def test_get_chat_member_count(bot: Bot): """ getChatMembersCount method test """ from .types.dataset import CHAT chat = types.Chat(**CHAT) count = 5 async with FakeTelegram(message_data=count): - result = await bot.get_chat_members_count(chat_id=chat.id) + result = await bot.get_chat_member_count(chat_id=chat.id) assert result == count @@ -450,7 +450,7 @@ async def test_get_chat_member(bot: Bot): """ getChatMember method test """ from .types.dataset import CHAT, CHAT_MEMBER chat = types.Chat(**CHAT) - member = types.ChatMember(**CHAT_MEMBER) + member = types.ChatMember.resolve(**CHAT_MEMBER) async with FakeTelegram(message_data=CHAT_MEMBER): result = await bot.get_chat_member(chat_id=chat.id, user_id=member.user.id) diff --git a/tests/types/test_chat_member.py b/tests/types/test_chat_member.py index 2cea44ce..2fe3e677 100644 --- a/tests/types/test_chat_member.py +++ b/tests/types/test_chat_member.py @@ -1,7 +1,7 @@ from aiogram import types from .dataset import CHAT_MEMBER -chat_member = types.ChatMember(**CHAT_MEMBER) +chat_member = types.ChatMember.resolve(**CHAT_MEMBER) def test_export():