diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 65baad51..1c33729f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ Fixes # (issue) Please delete options that are not relevant. -- [ ] Documentstion (typos, code examples or any documentation update) +- [ ] Documentation (typos, code examples or any documentation update) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) diff --git a/README.md b/README.md index 9f977023..155b2848 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # AIOGram +[![Financial Contributors on Open Collective](https://opencollective.com/aiogram/all/badge.svg?style=flat-square)](https://opencollective.com/aiogram) [![\[Telegram\] aiogram live](https://img.shields.io/badge/telegram-aiogram-blue.svg?style=flat-square)](https://t.me/aiogram_live) [![PyPi Package Version](https://img.shields.io/pypi/v/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![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) @@ -24,3 +25,33 @@ You can [read the docs here](http://aiogram.readthedocs.io/en/latest/). - Source: [Github repo](https://github.com/aiogram/aiogram) - Issues/Bug tracker: [Github issues tracker](https://github.com/aiogram/aiogram/issues) - Test bot: [@aiogram_bot](https://t.me/aiogram_bot) + +## Contributors + +### Code Contributors + +This project exists thanks to all the people who contribute. [[Code of conduct](CODE_OF_CONDUCT.md)]. + + +### Financial Contributors + +Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/aiogram/contribute)] + +#### Individuals + + + +#### Organizations + +Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/aiogram/contribute)] + + + + + + + + + + + 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 b0fc3725..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 @@ -337,12 +338,13 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) - payload = generate_payload(**locals(), exclude=['audio']) + payload = generate_payload(**locals(), exclude=['audio', 'thumb']) if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) files = {} 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) @@ -1014,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, @@ -1030,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 @@ -1047,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 @@ -1099,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/files.py b/aiogram/contrib/fsm_storage/files.py index f67a6f69..455ca3f0 100644 --- a/aiogram/contrib/fsm_storage/files.py +++ b/aiogram/contrib/fsm_storage/files.py @@ -20,7 +20,8 @@ class _FileStorage(MemoryStorage): pass async def close(self): - self.write(self.path) + if self.data: + self.write(self.path) await super(_FileStorage, self).close() def read(self, path: pathlib.Path): 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/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index c3a91f00..106a7b97 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -35,7 +35,6 @@ class RedisStorage(BaseStorage): await dp.storage.wait_closed() """ - def __init__(self, host='localhost', port=6379, db=None, password=None, ssl=None, loop=None, **kwargs): self._host = host self._port = port @@ -62,8 +61,6 @@ class RedisStorage(BaseStorage): async def redis(self) -> aioredis.RedisConnection: """ Get Redis connection - - This property is awaitable. """ # Use thread-safe asyncio Lock because this method without that is not safe async with self._connection_lock: @@ -222,9 +219,12 @@ class RedisStorage2(BaseStorage): await dp.storage.wait_closed() """ - - def __init__(self, host='localhost', port=6379, db=None, password=None, ssl=None, - pool_size=10, loop=None, prefix='fsm', **kwargs): + def __init__(self, host: str = 'localhost', port=6379, db=None, password=None, + ssl=None, pool_size=10, loop=None, prefix='fsm', + state_ttl: int = 0, + data_ttl: int = 0, + bucket_ttl: int = 0, + **kwargs): self._host = host self._port = port self._db = db @@ -235,14 +235,16 @@ class RedisStorage2(BaseStorage): self._kwargs = kwargs self._prefix = (prefix,) + self._state_ttl = state_ttl + self._data_ttl = data_ttl + self._bucket_ttl = bucket_ttl + self._redis: aioredis.RedisConnection = None self._connection_lock = asyncio.Lock(loop=self._loop) async def redis(self) -> aioredis.Redis: """ Get Redis connection - - This property is awaitable. """ # Use thread-safe asyncio Lock because this method without that is not safe async with self._connection_lock: @@ -294,14 +296,14 @@ class RedisStorage2(BaseStorage): if state is None: await redis.delete(key) else: - await redis.set(key, state) + await redis.set(key, state, expire=self._state_ttl) async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None): 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)) + await redis.set(key, json.dumps(data), expire=self._data_ttl) async def update_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None, **kwargs): @@ -329,7 +331,7 @@ 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)) + await redis.set(key, json.dumps(bucket), expire=self._bucket_ttl) async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, @@ -338,7 +340,7 @@ class RedisStorage2(BaseStorage): bucket = {} temp_bucket = await self.get_bucket(chat=chat, user=user) temp_bucket.update(bucket, **kwargs) - await self.set_bucket(chat=chat, user=user, data=temp_bucket) + await self.set_bucket(chat=chat, user=user, bucket=temp_bucket) async def reset_all(self, full=True): """ diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 264bc653..8373f3d6 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -107,7 +107,7 @@ class I18nMiddleware(BaseMiddleware): else: return translator.ngettext(singular, plural, n) - def lazy_gettext(self, singular, plural=None, n=1, locale=None) -> LazyProxy: + def lazy_gettext(self, singular, plural=None, n=1, locale=None, enable_cache=True) -> LazyProxy: """ Lazy get text @@ -115,9 +115,10 @@ class I18nMiddleware(BaseMiddleware): :param plural: :param n: :param locale: + :param enable_cache: :return: """ - return LazyProxy(self.gettext, singular, plural, n, locale) + return LazyProxy(self.gettext, singular, plural, n, locale, enable_cache=enable_cache) # noinspection PyMethodMayBeStatic,PyUnusedLocal async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: diff --git a/aiogram/contrib/middlewares/logging.py b/aiogram/contrib/middlewares/logging.py index 1a3566c6..9f389b60 100644 --- a/aiogram/contrib/middlewares/logging.py +++ b/aiogram/contrib/middlewares/logging.py @@ -89,34 +89,39 @@ class LoggingMiddleware(BaseMiddleware): async def on_pre_process_callback_query(self, callback_query: types.CallbackQuery, data: dict): if callback_query.message: + text = (f"Received callback query [ID:{callback_query.id}] " + f"from user [ID:{callback_query.from_user.id}] " + f"for message [ID:{callback_query.message.message_id}] " + f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + if callback_query.message.from_user: - self.logger.info(f"Received callback query [ID:{callback_query.id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}] " - f"from user [ID:{callback_query.message.from_user.id}]") - else: - self.logger.info(f"Received callback query [ID:{callback_query.id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + text += f" originally posted by user [ID:{callback_query.message.from_user.id}]" + + self.logger.info(text) + else: self.logger.info(f"Received callback query [ID:{callback_query.id}] " - f"from inline message [ID:{callback_query.inline_message_id}] " - f"from user [ID:{callback_query.from_user.id}]") + f"from user [ID:{callback_query.from_user.id}] " + f"for inline message [ID:{callback_query.inline_message_id}] ") async def on_post_process_callback_query(self, callback_query, results, data: dict): if callback_query.message: + text = (f"{HANDLED_STR[bool(len(results))]} " + f"callback query [ID:{callback_query.id}] " + f"from user [ID:{callback_query.from_user.id}] " + f"for message [ID:{callback_query.message.message_id}] " + f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + if callback_query.message.from_user: - self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " - f"callback query [ID:{callback_query.id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}] " - f"from user [ID:{callback_query.message.from_user.id}]") - else: - self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " - f"callback query [ID:{callback_query.id}] " - f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}]") + text += f" originally posted by user [ID:{callback_query.message.from_user.id}]" + + self.logger.info(text) + else: self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"callback query [ID:{callback_query.id}] " - f"from inline message [ID:{callback_query.inline_message_id}] " - f"from user [ID:{callback_query.from_user.id}]") + f"from user [ID:{callback_query.from_user.id}]" + f"from inline message [ID:{callback_query.inline_message_id}]") async def on_pre_process_shipping_query(self, shipping_query: types.ShippingQuery, data: dict): self.logger.info(f"Received shipping query [ID:{shipping_query.id}] " diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index e11ff536..0da5f621 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 + RegexpCommandsFilter, StateFilter, Text, IdFilter from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -97,7 +97,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): 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.callback_query_handlers, self.poll_handlers, self.inline_query_handlers ]) filters_factory.bind(HashTag, event_handlers=[ self.message_handlers, self.edited_message_handlers, @@ -106,7 +106,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): 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.callback_query_handlers, self.poll_handlers, self.inline_query_handlers ]) filters_factory.bind(RegexpCommandsFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers @@ -114,6 +114,11 @@ class Dispatcher(DataMixin, ContextInstanceMixin): filters_factory.bind(ExceptionsFilter, event_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 + ]) def __del__(self): self.stop_polling() diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 2ae959cf..eb4a5a52 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 + ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, Text, IdFilter from .factory import FiltersFactory from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \ check_filters, get_filter_spec, get_filters_spec @@ -23,6 +23,7 @@ __all__ = [ 'Regexp', 'StateFilter', 'Text', + 'IdFilter', 'get_filter_spec', 'get_filters_spec', 'execute_filter', diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 011b9b67..15cd73dd 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -221,13 +221,13 @@ class Text(Filter): :param ignore_case: case insensitive """ # Only one mode can be used. check it. - check = sum(map(bool, (equals, contains, startswith, endswith))) + check = sum(map(lambda s: s is not None, (equals, contains, startswith, endswith))) if check > 1: args = "' and '".join([arg[0] for arg in [('equals', equals), ('contains', contains), ('startswith', startswith), ('endswith', endswith) - ] if arg[1]]) + ] if arg[1] is not None]) raise ValueError(f"Arguments '{args}' cannot be used together.") elif check == 0: raise ValueError(f"No one mode is specified!") @@ -249,7 +249,7 @@ class Text(Filter): elif 'text_endswith' in full_config: return {'endswith': full_config.pop('text_endswith')} - async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]): + async def check(self, obj: Union[Message, CallbackQuery, InlineQuery, Poll]): if isinstance(obj, Message): text = obj.text or obj.caption or '' if not text and obj.poll: @@ -266,14 +266,26 @@ class Text(Filter): if self.ignore_case: text = text.lower() - if self.equals: - return text == str(self.equals) - elif self.contains: - return str(self.contains) in text - elif self.startswith: - return text.startswith(str(self.startswith)) - elif self.endswith: - return text.endswith(str(self.endswith)) + 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) return False @@ -359,13 +371,17 @@ class Regexp(Filter): if 'regexp' in full_config: return {'regexp': full_config.pop('regexp')} - async def check(self, obj: Union[Message, CallbackQuery]): + async def check(self, obj: Union[Message, CallbackQuery, InlineQuery, Poll]): if isinstance(obj, Message): content = obj.text or obj.caption or '' if not content and obj.poll: content = obj.poll.question elif isinstance(obj, CallbackQuery) and obj.data: content = obj.data + elif isinstance(obj, InlineQuery): + content = obj.query + elif isinstance(obj, Poll): + content = obj.question else: return False @@ -487,3 +503,66 @@ class ExceptionsFilter(BoundFilter): return True except: return False + + +class IdFilter(Filter): + + def __init__(self, + user_id: Optional[Union[Iterable[Union[int, str]], str, int]] = None, + chat_id: Optional[Union[Iterable[Union[int, str]], str, int]] = None, + ): + """ + :param user_id: + :param chat_id: + """ + if user_id is None and chat_id is None: + raise ValueError("Both user_id and chat_id can't be None") + + self.user_id = None + self.chat_id = None + if user_id: + if isinstance(user_id, Iterable): + self.user_id = list(map(int, user_id)) + else: + self.user_id = [int(user_id), ] + if chat_id: + if isinstance(chat_id, Iterable): + self.chat_id = list(map(int, chat_id)) + else: + self.chat_id = [int(chat_id), ] + + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + result = {} + if 'user_id' in full_config: + result['user_id'] = full_config.pop('user_id') + + if 'chat_id' in full_config: + result['chat_id'] = full_config.pop('chat_id') + + return result + + async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]): + if isinstance(obj, Message): + user_id = obj.from_user.id + chat_id = obj.chat.id + elif isinstance(obj, CallbackQuery): + user_id = obj.from_user.id + chat_id = None + if obj.message is not None: + # if the button was sent with message + chat_id = obj.message.chat.id + elif isinstance(obj, InlineQuery): + user_id = obj.from_user.id + chat_id = None + else: + return False + + 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: + return user_id in self.user_id + elif self.chat_id: + return chat_id in self.chat_id + + return False diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 46e44fc9..4806c55a 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -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/handler.py b/aiogram/dispatcher/handler.py index 859cb47e..889dc8d6 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -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): @@ -56,7 +55,7 @@ class Handler: :param filters: list of filters :param index: you can reorder handlers """ - spec, handler = _get_spec(handler) + spec = _get_spec(handler) if filters and not isinstance(filters, (list, tuple, set)): filters = [filters] @@ -105,7 +104,7 @@ class Handler: try: for handler_obj in self.handlers: try: - data.update(await check_filters(handler_obj.filters, args + (data,))) + data.update(await check_filters(handler_obj.filters, args)) except FilterNotPassed: continue else: diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 4c06c2af..bee635ae 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -5,6 +5,7 @@ import functools import ipaddress import itertools import typing +import logging from typing import Dict, List, Optional, Union from aiohttp import web @@ -35,6 +36,8 @@ TELEGRAM_SUBNET_2 = ipaddress.IPv4Network('91.108.4.0/22') allowed_ips = set() +log = logging.getLogger(__name__) + def _check_ip(ip: str) -> bool: """ @@ -77,7 +80,7 @@ class WebhookRequestHandler(web.View): .. code-block:: python3 - app.router.add_route('*', '/your/webhook/path', WebhookRequestHadler, name='webhook_handler') + app.router.add_route('*', '/your/webhook/path', WebhookRequestHandler, name='webhook_handler') But first you need to configure application for getting Dispatcher instance from request handler! It must always be with key 'BOT_DISPATCHER' @@ -258,7 +261,9 @@ class WebhookRequestHandler(web.View): if self.request.app.get('_check_ip', False): ip_address, accept = self.check_ip() if not accept: + log.warning(f"Blocking request from an unauthorized IP: {ip_address}") raise web.HTTPUnauthorized() + # context.set_value('TELEGRAM_IP', ip_address) 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 89506400..7e05a33f 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -29,6 +29,7 @@ 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() 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/input_media.py b/aiogram/types/input_media.py index 7bb58a7a..95ca75ae 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -26,7 +26,7 @@ class InputMedia(base.TelegramObject): media: base.String = fields.Field(alias='media', on_change='_media_changed') thumb: typing.Union[base.InputFile, base.String] = fields.Field(alias='thumb', on_change='_thumb_changed') caption: base.String = fields.Field() - parse_mode: base.Boolean = fields.Field() + parse_mode: base.String = fields.Field() def __init__(self, *args, **kwargs): self._thumb_file = None @@ -110,7 +110,7 @@ class InputMediaAnimation(InputMedia): thumb: typing.Union[base.InputFile, base.String] = None, caption: base.String = None, width: base.Integer = None, height: base.Integer = None, duration: base.Integer = None, - parse_mode: base.Boolean = None, **kwargs): + parse_mode: base.String = None, **kwargs): super(InputMediaAnimation, self).__init__(type='animation', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, parse_mode=parse_mode, conf=kwargs) @@ -124,7 +124,7 @@ class InputMediaDocument(InputMedia): """ def __init__(self, media: base.InputFile, thumb: typing.Union[base.InputFile, base.String] = None, - caption: base.String = None, parse_mode: base.Boolean = None, **kwargs): + caption: base.String = None, parse_mode: base.String = None, **kwargs): super(InputMediaDocument, self).__init__(type='document', media=media, thumb=thumb, caption=caption, parse_mode=parse_mode, conf=kwargs) @@ -150,7 +150,7 @@ class InputMediaAudio(InputMedia): duration: base.Integer = None, performer: base.String = None, title: base.String = None, - parse_mode: base.Boolean = None, **kwargs): + parse_mode: base.String = None, **kwargs): super(InputMediaAudio, self).__init__(type='audio', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, performer=performer, title=title, @@ -165,7 +165,7 @@ class InputMediaPhoto(InputMedia): """ def __init__(self, media: base.InputFile, thumb: typing.Union[base.InputFile, base.String] = None, - caption: base.String = None, parse_mode: base.Boolean = None, **kwargs): + caption: base.String = None, parse_mode: base.String = None, **kwargs): super(InputMediaPhoto, self).__init__(type='photo', media=media, thumb=thumb, caption=caption, parse_mode=parse_mode, conf=kwargs) @@ -186,7 +186,7 @@ class InputMediaVideo(InputMedia): thumb: typing.Union[base.InputFile, base.String] = None, caption: base.String = None, width: base.Integer = None, height: base.Integer = None, duration: base.Integer = None, - parse_mode: base.Boolean = None, + parse_mode: base.String = None, supports_streaming: base.Boolean = None, **kwargs): super(InputMediaVideo, self).__init__(type='video', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, @@ -277,7 +277,7 @@ class MediaGroup(base.TelegramObject): duration: base.Integer = None, performer: base.String = None, title: base.String = None, - parse_mode: base.Boolean = None): + parse_mode: base.String = None): """ Attach animation @@ -299,7 +299,7 @@ class MediaGroup(base.TelegramObject): self.attach(audio) def attach_document(self, document: base.InputFile, thumb: typing.Union[base.InputFile, base.String] = None, - caption: base.String = None, parse_mode: base.Boolean = None): + caption: base.String = None, parse_mode: base.String = None): """ Attach document diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 7637cf42..67fd07fa 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -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/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/utils/exceptions.py b/aiogram/utils/exceptions.py index afd623cc..a6612547 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -490,7 +490,7 @@ class Unauthorized(TelegramAPIError, _MatchErrorMixin): class BotKicked(Unauthorized): - match = 'Bot was kicked from a chat' + match = 'bot was kicked from a chat' class BotBlocked(Unauthorized): diff --git a/docs/source/dispatcher/filters.rst b/docs/source/dispatcher/filters.rst index d103ac36..af03e163 100644 --- a/docs/source/dispatcher/filters.rst +++ b/docs/source/dispatcher/filters.rst @@ -111,6 +111,14 @@ ExceptionsFilter :show-inheritance: +IdFilter +---------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.IdFilter + :members: + :show-inheritance: + + Making own filters (Custom filters) =================================== 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/examples/broadcast_example.py b/examples/broadcast_example.py index 9e654d44..2891bb30 100644 --- a/examples/broadcast_example.py +++ b/examples/broadcast_example.py @@ -9,9 +9,8 @@ API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.INFO) log = logging.getLogger('broadcast') -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop, parse_mode=types.ParseMode.HTML) -dp = Dispatcher(bot, loop=loop) +bot = Bot(token=API_TOKEN, parse_mode=types.ParseMode.HTML) +dp = Dispatcher(bot) def get_users(): diff --git a/examples/callback_data_factory.py b/examples/callback_data_factory.py index 3dd7d35e..8fd197df 100644 --- a/examples/callback_data_factory.py +++ b/examples/callback_data_factory.py @@ -13,8 +13,8 @@ logging.basicConfig(level=logging.INFO) API_TOKEN = 'BOT TOKEN HERE' -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop, parse_mode=types.ParseMode.HTML) + +bot = Bot(token=API_TOKEN, parse_mode=types.ParseMode.HTML) storage = MemoryStorage() dp = Dispatcher(bot, storage=storage) dp.middleware.setup(LoggingMiddleware()) diff --git a/examples/callback_data_factory_simple.py b/examples/callback_data_factory_simple.py new file mode 100644 index 00000000..2c6a8358 --- /dev/null +++ b/examples/callback_data_factory_simple.py @@ -0,0 +1,70 @@ +""" +This is a simple example of usage of CallbackData factory +For more comprehensive example see callback_data_factory.py +""" + +import logging + +from aiogram import Bot, Dispatcher, executor, types +from aiogram.contrib.middlewares.logging import LoggingMiddleware +from aiogram.utils.callback_data import CallbackData +from aiogram.utils.exceptions import MessageNotModified + +logging.basicConfig(level=logging.INFO) + +API_TOKEN = 'BOT_TOKEN_HERE' + + +bot = Bot(token=API_TOKEN) + +dp = Dispatcher(bot) +dp.middleware.setup(LoggingMiddleware()) + +vote_cb = CallbackData('vote', 'action') # vote: +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'))) + + +@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()) + + +@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] + + 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()) + + +@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()) + + +@dp.errors_handler(exception=MessageNotModified) # handle the cases when this exception raises +async def message_not_modified_handler(update, error): + # pass + return True + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/check_user_language.py b/examples/check_user_language.py index bd0ba7f9..f59246cf 100644 --- a/examples/check_user_language.py +++ b/examples/check_user_language.py @@ -11,8 +11,8 @@ API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.INFO) -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop, parse_mode=types.ParseMode.MARKDOWN) + +bot = Bot(token=API_TOKEN, parse_mode=types.ParseMode.MARKDOWN) dp = Dispatcher(bot) diff --git a/examples/finite_state_machine_example.py b/examples/finite_state_machine_example.py index 58b8053c..66f89fb2 100644 --- a/examples/finite_state_machine_example.py +++ b/examples/finite_state_machine_example.py @@ -11,9 +11,7 @@ from aiogram.utils import executor API_TOKEN = 'BOT TOKEN HERE' -loop = asyncio.get_event_loop() - -bot = Bot(token=API_TOKEN, loop=loop) +bot = Bot(token=API_TOKEN) # For example use simple MemoryStorage for Dispatcher. storage = MemoryStorage() @@ -112,9 +110,9 @@ async def process_gender(message: types.Message, state: FSMContext): md.text('Gender:', data['gender']), sep='\n'), reply_markup=markup, parse_mode=ParseMode.MARKDOWN) - # Finish conversation - data.state = None + # Finish conversation + await state.finish() if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/id_filter_example.py b/examples/id_filter_example.py new file mode 100644 index 00000000..64dc3b3f --- /dev/null +++ b/examples/id_filter_example.py @@ -0,0 +1,38 @@ +from aiogram import Bot, Dispatcher, executor, types +from aiogram.dispatcher.handler import SkipHandler + +API_TOKEN = 'API_TOKE_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 + + +@dp.message_handler(user_id=user_id_to_test) +async def handler1(msg: types.Message): + await bot.send_message(msg.chat.id, + "Hello, checking with user_id=") + raise SkipHandler + + +@dp.message_handler(chat_id=chat_id_to_test) +async def handler2(msg: types.Message): + await bot.send_message(msg.chat.id, + "Hello, checking with chat_id=") + raise SkipHandler + + +@dp.message_handler(user_id=user_id_to_test, chat_id=chat_id_to_test) +async def handler3(msg: types.Message): + await bot.send_message(msg.chat.id, + "Hello from user= & chat_id=") + + +@dp.message_handler(user_id=[user_id_to_test, 123]) # todo: add second id here +async def handler4(msg: types.Message): + print("Checked user_id with list!") + + +if __name__ == '__main__': + executor.start_polling(dp) diff --git a/examples/inline_bot.py b/examples/inline_bot.py index 4a771210..f1a81bb4 100644 --- a/examples/inline_bot.py +++ b/examples/inline_bot.py @@ -7,8 +7,7 @@ API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.DEBUG) -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop) +bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) @@ -21,4 +20,4 @@ async def inline_echo(inline_query: types.InlineQuery): if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/inline_keyboard_example.py b/examples/inline_keyboard_example.py new file mode 100644 index 00000000..2478b9e0 --- /dev/null +++ b/examples/inline_keyboard_example.py @@ -0,0 +1,56 @@ +""" +This bot is created for the demonstration of a usage of inline keyboards. +""" + +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']) +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')) + + keyboard_markup.add(types.InlineKeyboardButton("aiogram link", + url='https://github.com/aiogram/aiogram')) + # url buttons has no callback data + + 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' +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 + if answer_data == 'yes': + await bot.send_message(query.from_user.id, "That's great!") + elif answer_data == 'no': + await bot.send_message(query.from_user.id, "Oh no...Why so?") + else: + await bot.send_message(query.from_user.id, "Invalid callback data!") + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/media_group.py b/examples/media_group.py index b1f5246a..eafbac6a 100644 --- a/examples/media_group.py +++ b/examples/media_group.py @@ -4,8 +4,7 @@ from aiogram import Bot, Dispatcher, executor, filters, types API_TOKEN = 'BOT TOKEN HERE' -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop) +bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) @@ -40,4 +39,4 @@ async def send_welcome(message: types.Message): if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, skip_updates=True) diff --git a/examples/middleware_and_antiflood.py b/examples/middleware_and_antiflood.py index c579aecc..4a0cc491 100644 --- a/examples/middleware_and_antiflood.py +++ b/examples/middleware_and_antiflood.py @@ -9,12 +9,10 @@ from aiogram.utils.exceptions import Throttled TOKEN = 'BOT TOKEN HERE' -loop = asyncio.get_event_loop() - # In this example Redis storage is used storage = RedisStorage2(db=5) -bot = Bot(token=TOKEN, loop=loop) +bot = Bot(token=TOKEN) dp = Dispatcher(bot, storage=storage) @@ -119,4 +117,4 @@ if __name__ == '__main__': dp.middleware.setup(ThrottlingMiddleware()) # Start long-polling - executor.start_polling(dp, loop=loop) + executor.start_polling(dp) diff --git a/examples/payments.py b/examples/payments.py index e8e37011..a01fbaf3 100644 --- a/examples/payments.py +++ b/examples/payments.py @@ -9,9 +9,8 @@ from aiogram.utils import executor BOT_TOKEN = 'BOT TOKEN HERE' PAYMENTS_PROVIDER_TOKEN = '123456789:TEST:1234567890abcdef1234567890abcdef' -loop = asyncio.get_event_loop() bot = Bot(BOT_TOKEN) -dp = Dispatcher(bot, loop=loop) +dp = Dispatcher(bot) # Setup prices prices = [ @@ -96,4 +95,4 @@ async def got_payment(message: types.Message): if __name__ == '__main__': - executor.start_polling(dp, loop=loop) + executor.start_polling(dp) diff --git a/examples/proxy_and_emojize.py b/examples/proxy_and_emojize.py index 7e4452ee..17e33872 100644 --- a/examples/proxy_and_emojize.py +++ b/examples/proxy_and_emojize.py @@ -25,8 +25,7 @@ GET_IP_URL = 'http://bot.whatismyipaddress.com/' logging.basicConfig(level=logging.INFO) -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop, proxy=PROXY_URL) +bot = Bot(token=API_TOKEN, proxy=PROXY_URL) dp = Dispatcher(bot) @@ -62,4 +61,4 @@ async def cmd_start(message: types.Message): if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) + start_polling(dp, skip_updates=True) diff --git a/examples/regular_keyboard_example.py b/examples/regular_keyboard_example.py new file mode 100644 index 00000000..350e007e --- /dev/null +++ b/examples/regular_keyboard_example.py @@ -0,0 +1,61 @@ +""" +This bot is created for the demonstration of a usage of regular keyboards. +""" + +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']) +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!")) + # 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 + + await message.reply("Hi!\nDo you love aiogram?", reply_markup=keyboard_markup) + + +@dp.message_handler() +async def all_msg_handler(message: types.Message): + # pressing of a KeyboardButton is the same as sending the regular message with the same text + # so, to handle the responses from the keyboard, we need to use a message_handler + # 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 + + 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()) + else: + await message.reply("Keep calm...Everything is fine", reply_markup=types.ReplyKeyboardRemove()) + # with message, we send types.ReplyKeyboardRemove() to hide the keyboard + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/throtling_example.py b/examples/throtling_example.py index b979a979..2641b44b 100644 --- a/examples/throtling_example.py +++ b/examples/throtling_example.py @@ -17,8 +17,7 @@ API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.INFO) -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop) +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. @@ -40,4 +39,4 @@ async def send_welcome(message: types.Message): if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) + start_polling(dp, skip_updates=True) diff --git a/examples/webhook_example.py b/examples/webhook_example.py index 86520988..0f6ae3cd 100644 --- a/examples/webhook_example.py +++ b/examples/webhook_example.py @@ -37,8 +37,7 @@ WEBAPP_PORT = 3001 BAD_CONTENT = ContentTypes.PHOTO & ContentTypes.DOCUMENT & ContentTypes.STICKER & ContentTypes.AUDIO -loop = asyncio.get_event_loop() -bot = Bot(TOKEN, loop=loop) +bot = Bot(TOKEN) storage = MemoryStorage() dp = Dispatcher(bot, storage=storage) diff --git a/examples/webhook_example_2.py b/examples/webhook_example_2.py index 75b29c75..e2d9225c 100644 --- a/examples/webhook_example_2.py +++ b/examples/webhook_example_2.py @@ -18,8 +18,7 @@ WEBAPP_PORT = 3001 logging.basicConfig(level=logging.INFO) -loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop) +bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 00000000..da530910 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,197 @@ +import pytest + +from aiogram.dispatcher.filters import Text +from aiogram.types import Message, CallbackQuery, InlineQuery, Poll + + +class TestTextFilter: + @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), + ]) + async def test_startswith(self, test_prefix, test_text, ignore_case): + test_filter = Text(startswith=test_prefix, ignore_case=ignore_case) + + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_prefix = test_prefix.lower() + _test_text = test_text.lower() + else: + _test_prefix = test_prefix + _test_text = test_text + + 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)) + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_postfix, 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), + ]) + async def test_endswith(self, test_postfix, test_text, ignore_case): + test_filter = Text(endswith=test_postfix, ignore_case=ignore_case) + + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_postfix = test_postfix.lower() + _test_text = test_text.lower() + else: + _test_postfix = test_postfix + _test_text = test_text + + 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)) + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_string, 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_strin', True), + ('example_string', 'not_eXample_strin', True), + ('EXample_string', 'not_eXample_strin', True), + + ('example_string', 'not_example_strin', False), + ('example_string', 'not_eXample_strin', False), + ('EXample_string', 'not_example_strin', False), + ]) + async def test_contains(self, test_string, test_text, ignore_case): + test_filter = Text(contains=test_string, ignore_case=ignore_case) + + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_string = test_string.lower() + _test_text = test_text.lower() + else: + _test_string = test_string + _test_text = test_text + + 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)) + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_filter_text, 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', '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), + ]) + async def test_equals_string(self, test_filter_text, test_text, ignore_case): + test_filter = Text(equals=test_filter_text, ignore_case=ignore_case) + + async def check(obj): + result = await test_filter.check(obj) + if ignore_case: + _test_filter_text = test_filter_text.lower() + _test_text = test_text.lower() + else: + _test_filter_text = test_filter_text + _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))