diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..82ea7257 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [JRootJunior] +open_collective: aiogram diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..c022e7f7 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,19 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +# Optionally set the version of Python and requirements required to build your docs +python: + version: 3.7 + install: + - requirements: dev_requirements.txt diff --git a/README.md b/README.md index 639a6ff9..9f977023 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![Documentation Status](https://img.shields.io/readthedocs/pip/stable.svg?style=flat-square)](http://aiogram.readthedocs.io/en/latest/?badge=latest) [![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) diff --git a/README.rst b/README.rst index fde5bc7f..0377aad9 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,10 @@ 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 + :target: https://core.telegram.org/bots/api + :alt: Telegram Bot API + .. image:: https://img.shields.io/readthedocs/pip/stable.svg?style=flat-square :target: http://aiogram.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 321a4e34..a1c2736b 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -38,5 +38,5 @@ __all__ = [ 'utils' ] -__version__ = '2.0.2.dev1' -__api_version__ = '4.1' +__version__ = '2.2.1.dev1' +__api_version__ = '4.3' diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 25935dac..6c51b295 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -34,7 +34,7 @@ def check_token(token: str) -> bool: return True -async def check_result(method_name: str, content_type: str, status_code: int, body: str): +def check_result(method_name: str, content_type: str, status_code: int, body: str): """ Checks whether `result` is a valid API response. A result is considered invalid if: @@ -95,7 +95,7 @@ async def make_request(session, token, method, data=None, files=None, **kwargs): req = compose_data(data, files) try: async with session.post(url, data=req, **kwargs) as response: - return await check_result(method, response.content_type, response.status, await response.text()) + return check_result(method, response.content_type, response.status, await response.text()) except aiohttp.ClientError as e: raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") @@ -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.1 + List is updated to Bot API 4.3 """ mode = HelperMode.lowerCamelCase @@ -174,6 +174,7 @@ class Methods(Helper): STOP_MESSAGE_LIVE_LOCATION = Item() # stopMessageLiveLocation SEND_VENUE = Item() # sendVenue SEND_CONTACT = Item() # sendContact + SEND_POLL = Item() # sendPoll SEND_CHAT_ACTION = Item() # sendChatAction GET_USER_PROFILE_PHOTOS = Item() # getUserProfilePhotos GET_FILE = Item() # getFile @@ -202,6 +203,7 @@ class Methods(Helper): EDIT_MESSAGE_CAPTION = Item() # editMessageCaption EDIT_MESSAGE_MEDIA = Item() # editMessageMedia EDIT_MESSAGE_REPLY_MARKUP = Item() # editMessageReplyMarkup + STOP_POLL = Item() # stopPoll DELETE_MESSAGE = Item() # deleteMessage # Stickers diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index d612a13b..85773e30 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -1,10 +1,14 @@ import asyncio +import contextlib import io import ssl +import typing +from contextvars import ContextVar from typing import Dict, List, Optional, Union import aiohttp import certifi +from aiohttp.helpers import sentinel from . import api from ..types import ParseMode, base @@ -16,13 +20,20 @@ class BaseBot: """ Base class for bot. It's raw bot. """ + _ctx_timeout = ContextVar('TelegramRequestTimeout') + _ctx_token = ContextVar('BotDifferentToken') - def __init__(self, token: base.String, - loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None, - connections_limit: Optional[base.Integer] = None, - proxy: Optional[base.String] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, - validate_token: Optional[base.Boolean] = True, - parse_mode=None): + def __init__( + self, + token: base.String, + loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None, + connections_limit: Optional[base.Integer] = None, + proxy: Optional[base.String] = None, + proxy_auth: Optional[aiohttp.BasicAuth] = None, + validate_token: Optional[base.Boolean] = True, + parse_mode: typing.Optional[base.String] = None, + timeout: typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]] = None + ): """ Instructions how to get Bot token is found here: https://core.telegram.org/bots#3-how-do-i-create-a-bot @@ -40,11 +51,14 @@ class BaseBot: :type validate_token: :obj:`bool` :param parse_mode: You can set default parse mode :type parse_mode: :obj:`str` + :param timeout: Request timeout + :type timeout: :obj:`typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]]` :raise: when token is invalid throw an :obj:`aiogram.utils.exceptions.ValidationError` """ # Authentication if validate_token: api.check_token(token) + self._token = None self.__token = token self.proxy = proxy @@ -77,13 +91,71 @@ class BaseBot: self.proxy = None self.proxy_auth = None else: - connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, - loop=self.loop) + connector = aiohttp.TCPConnector(limit=connections_limit, ssl=ssl_context, loop=self.loop) + self._timeout = None + self.timeout = timeout self.session = aiohttp.ClientSession(connector=connector, loop=self.loop, json_serialize=json.dumps) self.parse_mode = parse_mode + @staticmethod + def _prepare_timeout( + value: typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]] + ) -> typing.Optional[aiohttp.ClientTimeout]: + if value is None or isinstance(value, aiohttp.ClientTimeout): + return value + return aiohttp.ClientTimeout(total=value) + + @property + def timeout(self): + timeout = self._ctx_timeout.get(self._timeout) + if timeout is None: + return sentinel + return timeout + + @timeout.setter + def timeout(self, value): + self._timeout = self._prepare_timeout(value) + + @timeout.deleter + def timeout(self): + self.timeout = None + + @contextlib.contextmanager + def request_timeout(self, timeout: typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]): + """ + Context manager implements opportunity to change request timeout in current context + + :param timeout: Request timeout + :type timeout: :obj:`typing.Optional[typing.Union[base.Integer, base.Float, aiohttp.ClientTimeout]]` + :return: + """ + timeout = self._prepare_timeout(timeout) + token = self._ctx_timeout.set(timeout) + try: + yield + finally: + self._ctx_timeout.reset(token) + + @property + def __token(self): + return self._ctx_token.get(self._token) + + @__token.setter + def __token(self, value): + self._token = value + + @contextlib.contextmanager + def with_token(self, bot_token: base.String, validate_token: Optional[base.Boolean] = True): + if validate_token: + api.check_token(bot_token) + token = self._ctx_token.set(bot_token) + try: + yield + finally: + self._ctx_token.reset(token) + async def close(self): """ Close all client sessions @@ -109,11 +181,11 @@ class BaseBot: :raise: :obj:`aiogram.exceptions.TelegramApiError` """ return await api.make_request(self.session, self.__token, method, data, files, - proxy=self.proxy, proxy_auth=self.proxy_auth, **kwargs) + proxy=self.proxy, proxy_auth=self.proxy_auth, timeout=self.timeout, **kwargs) async def download_file(self, file_path: base.String, destination: Optional[base.InputFile] = None, - timeout: Optional[base.Integer] = 30, + timeout: Optional[base.Integer] = sentinel, chunk_size: Optional[base.Integer] = 65536, seek: Optional[base.Boolean] = True) -> Union[io.BytesIO, io.FileIO]: """ @@ -133,7 +205,7 @@ class BaseBot: if destination is None: destination = io.BytesIO() - url = api.Methods.file_url(token=self.__token, path=file_path) + url = self.get_file_url(file_path) dest = destination if isinstance(destination, io.IOBase) else open(destination, 'wb') async with self.session.get(url, timeout=timeout, proxy=self.proxy, proxy_auth=self.proxy_auth) as response: @@ -147,6 +219,9 @@ class BaseBot: dest.seek(0) return dest + def get_file_url(self, file_path): + return api.Methods.file_url(token=self.__token, path=file_path) + async def send_file(self, file_type, method, file, payload) -> Union[Dict, base.Boolean]: """ Send file diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 1f76823d..b0fc3725 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -201,7 +201,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -267,7 +268,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -327,7 +329,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -377,7 +380,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -438,7 +442,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -557,7 +562,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -605,7 +611,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -679,7 +686,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -794,7 +802,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -835,7 +844,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned @@ -847,6 +857,44 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): result = await self.request(api.Methods.SEND_CONTACT, payload) return types.Message(**result) + async def send_poll(self, chat_id: typing.Union[base.Integer, base.String], + question: base.String, + options: typing.List[base.String], + disable_notification: typing.Optional[base.Boolean], + reply_to_message_id: typing.Union[base.Integer, None], + reply_markup: typing.Union[types.InlineKeyboardMarkup, + types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, + types.ForceReply, None] = None) -> types.Message: + """ + Use this method to send a native poll. A native poll can't be sent to a private chat. + On success, the sent Message is returned. + + :param chat_id: Unique identifier for the target chat + or username of the target channel (in the format @channelusername). + A native poll can't be sent to a private chat. + :type chat_id: :obj:`typing.Union[base.Integer, base.String]` + :param question: Poll question, 1-255 characters + :type question: :obj:`base.String` + :param options: List of answer options, 2-10 strings 1-100 characters each + :param options: :obj:`typing.List[base.String]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :type disable_notification: :obj:`typing.Optional[Boolean]` + :param reply_to_message_id: If the message is a reply, ID of the original message + :type reply_to_message_id: :obj:`typing.Optional[Integer]` + :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]` + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` + """ + options = prepare_arg(options) + payload = generate_payload(**locals()) + + result = await self.request(api.Methods.SEND_POLL, payload) + return types.Message(**result) + async def send_chat_action(self, chat_id: typing.Union[base.Integer, base.String], action: base.String) -> base.Boolean: """ @@ -1524,18 +1572,38 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): return result return types.Message(**result) + async def stop_poll(self, chat_id: typing.Union[base.String, base.Integer], + message_id: base.Integer, + reply_markup: typing.Union[types.InlineKeyboardMarkup, None] = None) -> types.Poll: + """ + Use this method to stop a poll which was sent by the bot. + On success, the stopped Poll with the final results is returned. + + :param chat_id: Unique identifier for the target chat or username of the target channel + :type chat_id: :obj:`typing.Union[base.String, base.Integer]` + :param message_id: Identifier of the original message with the poll + :type message_id: :obj:`base.Integer` + :param reply_markup: A JSON-serialized object for a new message inline keyboard. + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, the stopped Poll with the final results is returned. + :rtype: :obj:`types.Poll` + """ + payload = generate_payload(**locals()) + + result = await self.request(api.Methods.STOP_POLL, payload) + return types.Poll(**result) + async def delete_message(self, chat_id: typing.Union[base.Integer, base.String], message_id: base.Integer) -> base.Boolean: """ - Use this method to delete a message, including service messages, with the following limitations + Use this method to delete a message, including service messages, with the following limitations: - A message can only be deleted if it was sent less than 48 hours ago. - - Bots can delete outgoing messages in groups and supergroups. + - Bots can delete outgoing messages in private chats, groups, and supergroups. + - Bots can delete incoming messages in private chats. - Bots granted can_post_messages permissions can delete outgoing messages in channels. - If the bot is an administrator of a group, it can delete any message there. - If the bot has can_delete_messages permission in a supergroup or a channel, it can delete any message there. - The following methods and objects allow your bot to handle stickers and sticker sets. - Source: https://core.telegram.org/bots/api#deletemessage :param chat_id: Unique identifier for the target chat or username of the target channel @@ -1574,7 +1642,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin): :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options + :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]` :return: On success, the sent Message is returned diff --git a/aiogram/contrib/fsm_storage/files.py b/aiogram/contrib/fsm_storage/files.py index 88960375..f67a6f69 100644 --- a/aiogram/contrib/fsm_storage/files.py +++ b/aiogram/contrib/fsm_storage/files.py @@ -15,7 +15,7 @@ class _FileStorage(MemoryStorage): path = self.path = pathlib.Path(path) try: - self._data = self.read(path) + self.data = self.read(path) except FileNotFoundError: pass diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index 8c1abd90..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: @@ -173,10 +170,10 @@ class RedisStorage(BaseStorage): conn = await self.redis() if full: - conn.execute('FLUSHDB') + await conn.execute('FLUSHDB') else: keys = await conn.execute('KEYS', 'fsm:*') - conn.execute('DEL', *keys) + await conn.execute('DEL', *keys) def has_bucket(self): return True @@ -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,16 +331,16 @@ 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, bucket: typing.Dict = None, **kwargs): if bucket is None: bucket = {} - temp_bucket = await self.get_data(chat=chat, user=user) + temp_bucket = await self.get_bucket(chat=chat, user=user) temp_bucket.update(bucket, **kwargs) - await self.set_data(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): """ @@ -350,10 +352,10 @@ class RedisStorage2(BaseStorage): conn = await self.redis() if full: - conn.flushdb() + await conn.flushdb() else: keys = await conn.keys(self.generate_key('*')) - conn.delete(*keys) + await conn.delete(*keys) async def get_states_list(self) -> typing.List[typing.Tuple[int]]: """ diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 65cb1400..264bc653 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -4,6 +4,7 @@ from contextvars import ContextVar from typing import Any, Dict, Tuple from babel import Locale +from babel.support import LazyProxy from ... import types from ...dispatcher.middlewares import BaseMiddleware @@ -106,6 +107,18 @@ class I18nMiddleware(BaseMiddleware): else: return translator.ngettext(singular, plural, n) + def lazy_gettext(self, singular, plural=None, n=1, locale=None) -> LazyProxy: + """ + Lazy get text + + :param singular: + :param plural: + :param n: + :param locale: + :return: + """ + return LazyProxy(self.gettext, singular, plural, n, locale) + # 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 ca6b628f..9f389b60 100644 --- a/aiogram/contrib/middlewares/logging.py +++ b/aiogram/contrib/middlewares/logging.py @@ -1,6 +1,7 @@ -import logging import time +import logging + from aiogram import types from aiogram.dispatcher.middlewares import BaseMiddleware @@ -88,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}] " @@ -139,3 +145,283 @@ class LoggingMiddleware(BaseMiddleware): timeout = self.check_timeout(update) if timeout > 0: self.logger.info(f"Process update [ID:{update.update_id}]: [failed] (in {timeout} ms)") + + +class LoggingFilter(logging.Filter): + """ + Extend LogRecord by data from Telegram Update object. + + Can be used in logging config: + .. code-block: python3 + + 'filters': { + 'telegram': { + '()': LoggingFilter, + 'include_content': True, + } + }, + ... + 'handlers': { + 'graypy': { + '()': GELFRabbitHandler, + 'url': 'amqp://localhost:5672/', + 'routing_key': '#', + 'localname': 'testapp', + 'filters': ['telegram'] + }, + }, + + """ + + def __init__(self, name='', prefix='tg', include_content=False): + """ + :param name: + :param prefix: prefix for all records + :param include_content: pass into record all data from Update object + """ + super(LoggingFilter, self).__init__(name=name) + + self.prefix = prefix + self.include_content = include_content + + def filter(self, record: logging.LogRecord): + """ + Extend LogRecord by data from Telegram Update object. + + :param record: + :return: + """ + update = types.Update.get_current(True) + if update: + for key, value in self.make_prefix(self.prefix, self.process_update(update)): + setattr(record, key, value) + + return True + + def process_update(self, update: types.Update): + """ + Parse Update object + + :param update: + :return: + """ + yield 'update_id', update.update_id + + if update.message: + yield 'update_type', 'message' + yield from self.process_message(update.message) + if update.edited_message: + yield 'update_type', 'edited_message' + yield from self.process_message(update.edited_message) + if update.channel_post: + yield 'update_type', 'channel_post' + yield from self.process_message(update.channel_post) + if update.edited_channel_post: + yield 'update_type', 'edited_channel_post' + yield from self.process_message(update.edited_channel_post) + if update.inline_query: + yield 'update_type', 'inline_query' + yield from self.process_inline_query(update.inline_query) + if update.chosen_inline_result: + yield 'update_type', 'chosen_inline_result' + yield from self.process_chosen_inline_result(update.chosen_inline_result) + if update.callback_query: + yield 'update_type', 'callback_query' + yield from self.process_callback_query(update.callback_query) + if update.shipping_query: + yield 'update_type', 'shipping_query' + yield from self.process_shipping_query(update.shipping_query) + if update.pre_checkout_query: + yield 'update_type', 'pre_checkout_query' + yield from self.process_pre_checkout_query(update.pre_checkout_query) + + def make_prefix(self, prefix, iterable): + """ + Add prefix to the label + + :param prefix: + :param iterable: + :return: + """ + if not prefix: + yield from iterable + + for key, value in iterable: + yield f"{prefix}_{key}", value + + def process_user(self, user: types.User): + """ + Generate user data + + :param user: + :return: + """ + if not user: + return + + yield 'user_id', user.id + if self.include_content: + yield 'user_full_name', user.full_name + if user.username: + yield 'user_name', f"@{user.username}" + + def process_chat(self, chat: types.Chat): + """ + Generate chat data + + :param chat: + :return: + """ + if not chat: + return + + yield 'chat_id', chat.id + yield 'chat_type', chat.type + if self.include_content: + yield 'chat_title', chat.full_name + if chat.username: + yield 'chat_name', f"@{chat.username}" + + def process_message(self, message: types.Message): + yield 'message_content_type', message.content_type + yield from self.process_user(message.from_user) + yield from self.process_chat(message.chat) + + if not self.include_content: + return + + if message.reply_to_message: + yield from self.make_prefix('reply_to', self.process_message(message.reply_to_message)) + if message.forward_from: + yield from self.make_prefix('forward_from', self.process_user(message.forward_from)) + if message.forward_from_chat: + yield from self.make_prefix('forward_from_chat', self.process_chat(message.forward_from_chat)) + if message.forward_from_message_id: + yield 'message_forward_from_message_id', message.forward_from_message_id + if message.forward_date: + yield 'message_forward_date', message.forward_date + if message.edit_date: + yield 'message_edit_date', message.edit_date + if message.media_group_id: + yield 'message_media_group_id', message.media_group_id + if message.author_signature: + yield 'message_author_signature', message.author_signature + + if message.text: + yield 'text', message.text or message.caption + yield 'html_text', message.html_text + elif message.audio: + yield 'audio', message.audio.file_id + elif message.animation: + yield 'animation', message.animation.file_id + elif message.document: + yield 'document', message.document.file_id + elif message.game: + yield 'game', message.game.title + elif message.photo: + yield 'photo', message.photo[-1].file_id + elif message.sticker: + yield 'sticker', message.sticker.file_id + elif message.video: + yield 'video', message.video.file_id + elif message.video_note: + yield 'video_note', message.video_note.file_id + elif message.voice: + yield 'voice', message.voice.file_id + elif message.contact: + yield 'contact_full_name', message.contact.full_name + yield 'contact_phone_number', message.contact.phone_number + elif message.venue: + yield 'venue_address', message.venue.address + yield 'location_latitude', message.venue.location.latitude + yield 'location_longitude', message.venue.location.longitude + elif message.location: + yield 'location_latitude', message.location.latitude + yield 'location_longitude', message.location.longitude + elif message.new_chat_members: + yield 'new_chat_members', [user.id for user in message.new_chat_members] + elif message.left_chat_member: + yield 'left_chat_member', [user.id for user in message.new_chat_members] + elif message.invoice: + yield 'invoice_title', message.invoice.title + yield 'invoice_description', message.invoice.description + yield 'invoice_start_parameter', message.invoice.start_parameter + yield 'invoice_currency', message.invoice.currency + yield 'invoice_total_amount', message.invoice.total_amount + elif message.successful_payment: + yield 'successful_payment_currency', message.successful_payment.currency + yield 'successful_payment_total_amount', message.successful_payment.total_amount + yield 'successful_payment_invoice_payload', message.successful_payment.invoice_payload + yield 'successful_payment_shipping_option_id', message.successful_payment.shipping_option_id + yield 'successful_payment_telegram_payment_charge_id', message.successful_payment.telegram_payment_charge_id + yield 'successful_payment_provider_payment_charge_id', message.successful_payment.provider_payment_charge_id + elif message.connected_website: + yield 'connected_website', message.connected_website + elif message.migrate_from_chat_id: + yield 'migrate_from_chat_id', message.migrate_from_chat_id + elif message.migrate_to_chat_id: + yield 'migrate_to_chat_id', message.migrate_to_chat_id + elif message.pinned_message: + yield from self.make_prefix('pinned_message', message.pinned_message) + elif message.new_chat_title: + yield 'new_chat_title', message.new_chat_title + elif message.new_chat_photo: + yield 'new_chat_photo', message.new_chat_photo[-1].file_id + # elif message.delete_chat_photo: + # yield 'delete_chat_photo', message.delete_chat_photo + # elif message.group_chat_created: + # yield 'group_chat_created', message.group_chat_created + # elif message.passport_data: + # yield 'passport_data', message.passport_data + + def process_inline_query(self, inline_query: types.InlineQuery): + yield 'inline_query_id', inline_query.id + yield from self.process_user(inline_query.from_user) + + if self.include_content: + yield 'inline_query_text', inline_query.query + if inline_query.location: + yield 'location_latitude', inline_query.location.latitude + yield 'location_longitude', inline_query.location.longitude + if inline_query.offset: + yield 'inline_query_offset', inline_query.offset + + def process_chosen_inline_result(self, chosen_inline_result: types.ChosenInlineResult): + yield 'chosen_inline_result_id', chosen_inline_result.result_id + yield from self.process_user(chosen_inline_result.from_user) + + if self.include_content: + yield 'inline_query_text', chosen_inline_result.query + if chosen_inline_result.location: + yield 'location_latitude', chosen_inline_result.location.latitude + yield 'location_longitude', chosen_inline_result.location.longitude + + def process_callback_query(self, callback_query: types.CallbackQuery): + yield from self.process_user(callback_query.from_user) + yield 'callback_query_data', callback_query.data + + if callback_query.message: + yield from self.make_prefix('callback_query_message', self.process_message(callback_query.message)) + if callback_query.inline_message_id: + yield 'callback_query_inline_message_id', callback_query.inline_message_id + if callback_query.chat_instance: + yield 'callback_query_chat_instance', callback_query.chat_instance + if callback_query.game_short_name: + yield 'callback_query_game_short_name', callback_query.game_short_name + + def process_shipping_query(self, shipping_query: types.ShippingQuery): + yield 'shipping_query_id', shipping_query.id + yield from self.process_user(shipping_query.from_user) + + if self.include_content: + yield 'shipping_query_invoice_payload', shipping_query.invoice_payload + + def process_pre_checkout_query(self, pre_checkout_query: types.PreCheckoutQuery): + yield 'pre_checkout_query_id', pre_checkout_query.id + yield from self.process_user(pre_checkout_query.from_user) + + if self.include_content: + yield 'pre_checkout_query_currency', pre_checkout_query.currency + yield 'pre_checkout_query_total_amount', pre_checkout_query.total_amount + yield 'pre_checkout_query_invoice_payload', pre_checkout_query.invoice_payload + yield 'pre_checkout_query_shipping_option_id', pre_checkout_query.shipping_option_id diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 035ec1f5..e11ff536 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -5,7 +5,10 @@ import logging import time import typing -from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, FuncFilter, HashTag, Regexp, \ +import aiohttp +from aiohttp.helpers import sentinel + +from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \ RegexpCommandsFilter, StateFilter, Text from .handler import Handler from .middlewares import MiddlewareManager @@ -35,6 +38,9 @@ class Dispatcher(DataMixin, ContextInstanceMixin): throttling_rate_limit=DEFAULT_RATE_LIMIT, no_throttle_error=False, filters_factory=None): + if not isinstance(bot, Bot): + raise TypeError(f"Argument 'bot' must be an instance of Bot, not '{type(bot).__name__}'") + if loop is None: loop = bot.loop if storage is None: @@ -61,6 +67,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self.callback_query_handlers = Handler(self, middleware_key='callback_query') self.shipping_query_handlers = Handler(self, middleware_key='shipping_query') self.pre_checkout_query_handlers = Handler(self, middleware_key='pre_checkout_query') + self.poll_handlers = Handler(self, middleware_key='poll') self.errors_handlers = Handler(self, once=False, middleware_key='error') self.middleware = MiddlewareManager(self) @@ -77,7 +84,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin): filters_factory = self.filters_factory filters_factory.bind(StateFilter, exclude_event_handlers=[ - self.errors_handlers + self.errors_handlers, + self.poll_handlers ]) filters_factory.bind(ContentTypeFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers, @@ -89,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.callback_query_handlers, self.poll_handlers ]) filters_factory.bind(HashTag, event_handlers=[ self.message_handlers, self.edited_message_handlers, @@ -98,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.callback_query_handlers, self.poll_handlers ]) filters_factory.bind(RegexpCommandsFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers @@ -106,9 +114,6 @@ class Dispatcher(DataMixin, ContextInstanceMixin): filters_factory.bind(ExceptionsFilter, event_handlers=[ self.errors_handlers ]) - filters_factory.bind(FuncFilter, exclude_event_handlers=[ - self.errors_handlers - ]) def __del__(self): self.stop_polling() @@ -182,6 +187,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin): if update.pre_checkout_query: types.User.set_current(update.pre_checkout_query.from_user) return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) + if update.poll: + return await self.poll_handlers.notify(update.poll) except Exception as e: err = await self.errors_handlers.notify(update, e) if err: @@ -202,8 +209,13 @@ class Dispatcher(DataMixin, ContextInstanceMixin): return await self.bot.delete_webhook() - async def start_polling(self, timeout=20, relax=0.1, limit=None, reset_webhook=None, - fast: typing.Optional[bool] = True): + async def start_polling(self, + timeout=20, + relax=0.1, + limit=None, + reset_webhook=None, + fast: typing.Optional[bool] = True, + error_sleep: int = 5): """ Start long-polling @@ -231,12 +243,19 @@ class Dispatcher(DataMixin, ContextInstanceMixin): self._polling = True offset = None try: + current_request_timeout = self.bot.timeout + if current_request_timeout is not sentinel and timeout is not None: + request_timeout = aiohttp.ClientTimeout(total=current_request_timeout.total + timeout or 1) + else: + request_timeout = None + while self._polling: try: - updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) + with self.bot.request_timeout(request_timeout): + updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) except: log.exception('Cause exception while getting updates.') - await asyncio.sleep(15) + await asyncio.sleep(error_sleep) continue if updates: @@ -247,6 +266,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): if relax: await asyncio.sleep(relax) + finally: self._close_waiter._set_result(None) log.warning('Polling is stopped.') @@ -276,7 +296,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin): :return: """ - if self._polling: + if hasattr(self, '_polling') and self._polling: log.info('Stop polling...') self._polling = False @@ -793,6 +813,20 @@ class Dispatcher(DataMixin, ContextInstanceMixin): return decorator + def register_poll_handler(self, callback, *custom_filters, run_task=None, **kwargs): + filters_set = self.filters_factory.resolve(self.poll_handlers, + *custom_filters, + **kwargs) + self.poll_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def poll_handler(self, *custom_filters, run_task=None, **kwargs): + def decorator(callback): + self.register_poll_handler(callback, *custom_filters, run_task=run_task, + **kwargs) + return callback + + return decorator + def register_errors_handler(self, callback, *custom_filters, exception=None, run_task=None, **kwargs): """ Register handler for errors diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 489e7c76..2ae959cf 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,7 +1,8 @@ from .builtin import Command, CommandHelp, CommandPrivacy, CommandSettings, CommandStart, ContentTypeFilter, \ - ExceptionsFilter, FuncFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, Text + ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, Text from .factory import FiltersFactory -from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, check_filter, check_filters +from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \ + check_filters, get_filter_spec, get_filters_spec __all__ = [ 'AbstractFilter', @@ -14,7 +15,6 @@ __all__ = [ 'ContentTypeFilter', 'ExceptionsFilter', 'HashTag', - 'FuncFilter', 'Filter', 'FilterNotPassed', 'FilterRecord', @@ -23,6 +23,8 @@ __all__ = [ 'Regexp', 'StateFilter', 'Text', - 'check_filter', + 'get_filter_spec', + 'get_filters_spec', + 'execute_filter', 'check_filters' ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 04a4132c..011b9b67 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,18 +1,24 @@ import inspect import re +import typing from contextvars import ContextVar from dataclasses import dataclass, field from typing import Any, Dict, Iterable, Optional, Union +from babel.support import LazyProxy + from aiogram import types from aiogram.dispatcher.filters.filters import BoundFilter, Filter -from aiogram.types import CallbackQuery, Message, InlineQuery -from aiogram.utils.deprecated import warn_deprecated +from aiogram.types import CallbackQuery, Message, InlineQuery, Poll class Command(Filter): """ - You can handle commands by using this filter + You can handle commands by using this filter. + + If filter is successful processed the :obj:`Command.CommandObj` will be passed to the handler arguments. + + By default this filter is registered for messages and edited messages handlers. """ def __init__(self, commands: Union[Iterable, str], @@ -20,12 +26,22 @@ class Command(Filter): ignore_case: bool = True, ignore_mention: bool = False): """ - Filter can be initialized from filters factory or by simply creating instance of this class + Filter can be initialized from filters factory or by simply creating instance of this class. - :param commands: command or list of commands - :param prefixes: - :param ignore_case: - :param ignore_mention: + Examples: + + .. code-block:: python + + @dp.message_handler(commands=['myCommand']) + @dp.message_handler(Command(['myCommand'])) + @dp.message_handler(commands=['myCommand'], commands_prefix='!/') + + :param commands: Command or list of commands always without leading slashes (prefix) + :param prefixes: Allowed commands prefix. By default is slash. + If you change the default behavior pass the list of prefixes to this argument. + :param ignore_case: Ignore case of the command + :param ignore_mention: Ignore mention in command + (By default this filter pass only the commands addressed to current bot) """ if isinstance(commands, str): commands = (commands,) @@ -40,15 +56,21 @@ class Command(Filter): """ Validator for filters factory + From filters factory this filter can be registered with arguments: + + - ``command`` + - ``commands_prefix`` (will be passed as ``prefixes``) + - ``commands_ignore_mention`` (will be passed as ``ignore_mention`` + :param full_config: :return: config or empty dict """ config = {} if 'commands' in full_config: config['commands'] = full_config.pop('commands') - if 'commands_prefix' in full_config: + if config and 'commands_prefix' in full_config: config['prefixes'] = full_config.pop('commands_prefix') - if 'commands_ignore_mention' in full_config: + if config and 'commands_ignore_mention' in full_config: config['ignore_mention'] = full_config.pop('commands_ignore_mention') return config @@ -71,17 +93,37 @@ class Command(Filter): @dataclass class CommandObj: + """ + Instance of this object is always has command and it prefix. + + Can be passed as keyword argument ``command`` to the handler + """ + + """Command prefix""" prefix: str = '/' + """Command without prefix and mention""" command: str = '' + """Mention (if available)""" mention: str = None + """Command argument""" args: str = field(repr=False, default=None) @property def mentioned(self) -> bool: + """ + This command has mention? + + :return: + """ return bool(self.mention) @property def text(self) -> str: + """ + Generate original text from object + + :return: + """ line = self.prefix + self.command if self.mentioned: line += '@' + self.mention @@ -91,21 +133,69 @@ class Command(Filter): class CommandStart(Command): - def __init__(self): + """ + This filter based on :obj:`Command` filter but can handle only ``/start`` command. + """ + + def __init__(self, deep_link: typing.Optional[typing.Union[str, re.Pattern]] = None): + """ + Also this filter can handle `deep-linking `_ arguments. + + Example: + + .. code-block:: python + + @dp.message_handler(CommandStart(re.compile(r'ref-([\\d]+)'))) + + :param deep_link: string or compiled regular expression (by ``re.compile(...)``). + """ super(CommandStart, self).__init__(['start']) + self.deep_link = deep_link + + async def check(self, message: types.Message): + """ + If deep-linking is passed to the filter result of the matching will be passed as ``deep_link`` to the handler + + :param message: + :return: + """ + check = await super(CommandStart, self).check(message) + + if check and self.deep_link is not None: + if not isinstance(self.deep_link, re.Pattern): + return message.get_args() == self.deep_link + + match = self.deep_link.match(message.get_args()) + if match: + return {'deep_link': match} + return False + + return check class CommandHelp(Command): + """ + This filter based on :obj:`Command` filter but can handle only ``/help`` command. + """ + def __init__(self): super(CommandHelp, self).__init__(['help']) class CommandSettings(Command): + """ + This filter based on :obj:`Command` filter but can handle only ``/settings`` command. + """ + def __init__(self): super(CommandSettings, self).__init__(['settings']) class CommandPrivacy(Command): + """ + This filter based on :obj:`Command` filter but can handle only ``/privacy`` command. + """ + def __init__(self): super(CommandPrivacy, self).__init__(['privacy']) @@ -116,10 +206,10 @@ class Text(Filter): """ def __init__(self, - equals: Optional[str] = None, - contains: Optional[str] = None, - startswith: Optional[str] = None, - endswith: Optional[str] = None, + equals: Optional[Union[str, LazyProxy]] = None, + contains: Optional[Union[str, LazyProxy]] = None, + startswith: Optional[Union[str, LazyProxy]] = None, + endswith: Optional[Union[str, LazyProxy]] = None, ignore_case=False): """ Check text for one of pattern. Only one mode can be used in one filter. @@ -162,10 +252,14 @@ class Text(Filter): async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]): if isinstance(obj, Message): text = obj.text or obj.caption or '' + if not text and obj.poll: + text = obj.poll.question elif isinstance(obj, CallbackQuery): text = obj.data elif isinstance(obj, InlineQuery): text = obj.query + elif isinstance(obj, Poll): + text = obj.question else: return False @@ -173,13 +267,13 @@ class Text(Filter): text = text.lower() if self.equals: - return text == self.equals + return text == str(self.equals) elif self.contains: - return self.contains in text + return str(self.contains) in text elif self.startswith: - return text.startswith(self.startswith) + return text.startswith(str(self.startswith)) elif self.endswith: - return text.endswith(self.endswith) + return text.endswith(str(self.endswith)) return False @@ -267,12 +361,16 @@ class Regexp(Filter): async def check(self, obj: Union[Message, CallbackQuery]): if isinstance(obj, Message): - match = self.regexp.search(obj.text or obj.caption or '') + content = obj.text or obj.caption or '' + if not content and obj.poll: + content = obj.poll.question elif isinstance(obj, CallbackQuery) and obj.data: - match = self.regexp.search(obj.data) + content = obj.data else: return False + match = self.regexp.search(content) + if match: return {'regexp': match} return False @@ -372,22 +470,6 @@ class StateFilter(BoundFilter): return False -class FuncFilter(BoundFilter): - key = 'func' - - def __init__(self, dispatcher, func): - self.dispatcher = dispatcher - self.func = func - - warn_deprecated('"func" filter will be removed in 2.1 version.\n' - 'Read mode: https://aiogram.readthedocs.io/en/dev-2.x/migration_1_to_2.html#custom-filters', - stacklevel=8) - - async def check(self, obj) -> bool: - from .filters import check_filter - return await check_filter(self.dispatcher, self.func, (obj,)) - - class ExceptionsFilter(BoundFilter): """ Filter for exceptions diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 099a9b60..89e3e792 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -6,7 +6,7 @@ from ..handler import Handler class FiltersFactory: """ - Default filters factory + Filters factory """ def __init__(self, dispatcher): diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 7c0203f2..46e44fc9 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -2,7 +2,7 @@ import abc import inspect import typing -from ..handler import Handler +from ..handler import Handler, FilterObj class FilterNotPassed(Exception): @@ -20,15 +20,7 @@ def wrap_async(func): return async_wrapper -async def check_filter(dispatcher, filter_, args): - """ - Helper for executing filter - - :param dispatcher: - :param filter_: - :param args: - :return: - """ +def get_filter_spec(dispatcher, filter_: callable): kwargs = {} if not callable(filter_): raise TypeError('Filter must be callable and/or awaitable!') @@ -39,16 +31,37 @@ async def check_filter(dispatcher, filter_, args): if inspect.isawaitable(filter_) \ or inspect.iscoroutinefunction(filter_) \ or isinstance(filter_, AbstractFilter): - return await filter_(*args, **kwargs) + return FilterObj(filter=filter_, kwargs=kwargs, is_async=True) else: - return filter_(*args, **kwargs) + return FilterObj(filter=filter_, kwargs=kwargs, is_async=False) -async def check_filters(dispatcher, filters, args): +def get_filters_spec(dispatcher, filters: typing.Iterable[callable]): + data = [] + if filters is not None: + for i in filters: + data.append(get_filter_spec(dispatcher, i)) + return data + + +async def execute_filter(filter_: FilterObj, args): + """ + Helper for executing filter + + :param filter_: + :param args: + :return: + """ + if filter_.is_async: + return await filter_.filter(*args, **filter_.kwargs) + else: + return filter_.filter(*args, **filter_.kwargs) + + +async def check_filters(filters: typing.Iterable[FilterObj], args): """ Check list of filters - :param dispatcher: :param filters: :param args: :return: @@ -56,7 +69,7 @@ async def check_filters(dispatcher, filters, args): data = {} if filters is not None: for filter_ in filters: - f = await check_filter(dispatcher, filter_, args) + f = await execute_filter(filter_, args) if not f: raise FilterNotPassed() elif isinstance(f, dict): @@ -115,24 +128,29 @@ class FilterRecord: class AbstractFilter(abc.ABC): """ - Abstract class for custom filters + Abstract class for custom filters. """ @classmethod @abc.abstractmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: """ - Validate and parse config + Validate and parse config. - :param full_config: - :return: config + This method will be called by the filters factory when you bind this filter. + Must be overridden. + + :param full_config: dict with arguments passed to handler registrar + :return: Current filter config """ pass @abc.abstractmethod async def check(self, *args) -> bool: """ - Check object + Will be called when filters checks. + + This method must be overridden. :param args: :return: @@ -160,24 +178,46 @@ class AbstractFilter(abc.ABC): class Filter(AbstractFilter): """ - You can make subclasses of that class for custom filters + You can make subclasses of that class for custom filters. + + Method ``check`` must be overridden """ @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + """ + Here method ``validate`` is optional. + If you need to use filter from filters factory you need to override this method. + + :param full_config: dict with arguments passed to handler registrar + :return: Current filter config + """ pass class BoundFilter(Filter): """ - Base class for filters with default validator + To easily create your own filters with one parameter, you can inherit from this 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""" required = False + """Default value for configure required filters""" default = None @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """ + If ``cls.key`` is not :obj:`None` and that is in config returns config with that argument. + + :param full_config: + :return: + """ if cls.key is not None: if cls.key in full_config: return {cls.key: full_config[cls.key]} diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index 2caf80d8..17b715d1 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,10 +1,19 @@ import inspect from contextvars import ContextVar +from dataclasses import dataclass +from typing import Optional, Iterable ctx_data = ContextVar('ctx_handler_data') current_handler = ContextVar('current_handler') +@dataclass +class FilterObj: + filter: callable + kwargs: dict + is_async: bool + + class SkipHandler(Exception): pass @@ -13,11 +22,15 @@ class CancelHandler(Exception): pass -def _check_spec(func: callable, kwargs: dict): +def _get_spec(func: callable): while hasattr(func, '__wrapped__'): # Try to resolve decorated callbacks func = func.__wrapped__ spec = inspect.getfullargspec(func) + return spec, func + + +def _check_spec(spec: inspect.FullArgSpec, kwargs: dict): if spec.varkw: return kwargs @@ -33,6 +46,7 @@ class Handler: self.middleware_key = middleware_key def register(self, handler, filters=None, index=None): + from .filters import get_filters_spec """ Register callback @@ -42,9 +56,13 @@ class Handler: :param filters: list of filters :param index: you can reorder handlers """ + spec, handler = _get_spec(handler) + if filters and not isinstance(filters, (list, tuple, set)): filters = [filters] - record = (filters, handler) + filters = get_filters_spec(self.dispatcher, filters) + + record = Handler.HandlerObj(handler=handler, spec=spec, filters=filters) if index is None: self.handlers.append(record) else: @@ -57,10 +75,10 @@ class Handler: :param handler: callback :return: """ - for handler_with_filters in self.handlers: - _, registered = handler_with_filters + for handler_obj in self.handlers: + registered = handler_obj.handler if handler is registered: - self.handlers.remove(handler_with_filters) + self.handlers.remove(handler_obj) return True raise ValueError('This handler is not registered!') @@ -85,18 +103,18 @@ class Handler: return results try: - for filters, handler in self.handlers: + for handler_obj in self.handlers: try: - data.update(await check_filters(self.dispatcher, filters, args)) + data.update(await check_filters(handler_obj.filters, args)) except FilterNotPassed: continue else: - ctx_token = current_handler.set(handler) + ctx_token = current_handler.set(handler_obj.handler) try: if self.middleware_key: await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args + (data,)) - partial_data = _check_spec(handler, data) - response = await handler(*args, **partial_data) + partial_data = _check_spec(handler_obj.spec, data) + response = await handler_obj.handler(*args, **partial_data) if response is not None: results.append(response) if self.once: @@ -113,3 +131,9 @@ class Handler: args + (results, data,)) return results + + @dataclass + class HandlerObj: + handler: callable + spec: inspect.FullArgSpec + filters: Optional[Iterable[FilterObj]] = None diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 10a4b3c1..8e483255 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -3,13 +3,13 @@ import asyncio.tasks import datetime import functools import ipaddress +import itertools import typing from typing import Dict, List, Optional, Union from aiohttp import web from aiohttp.web_exceptions import HTTPGone - from .. import types from ..bot import api from ..types import ParseMode @@ -30,8 +30,8 @@ WEBHOOK = 'webhook' WEBHOOK_CONNECTION = 'WEBHOOK_CONNECTION' WEBHOOK_REQUEST = 'WEBHOOK_REQUEST' -TELEGRAM_IP_LOWER = ipaddress.IPv4Address('149.154.167.197') -TELEGRAM_IP_UPPER = ipaddress.IPv4Address('149.154.167.233') +TELEGRAM_SUBNET_1 = ipaddress.IPv4Network('149.154.160.0/20') +TELEGRAM_SUBNET_2 = ipaddress.IPv4Network('91.108.4.0/22') allowed_ips = set() @@ -47,18 +47,26 @@ def _check_ip(ip: str) -> bool: return address in allowed_ips -def allow_ip(*ips: str): +def allow_ip(*ips: typing.Union[str, ipaddress.IPv4Network, ipaddress.IPv4Address]): """ Allow ip address. :param ips: :return: """ - allowed_ips.update(ipaddress.IPv4Address(ip) for ip in ips) + for ip in ips: + if isinstance(ip, ipaddress.IPv4Address): + allowed_ips.add(ip) + elif isinstance(ip, str): + allowed_ips.add(ipaddress.IPv4Address(ip)) + elif isinstance(ip, ipaddress.IPv4Network): + allowed_ips.update(ip.hosts()) + else: + raise ValueError(f"Bad type of ipaddress: {type(ip)} ('{ip}')") # Allow access from Telegram servers -allow_ip(*(ip for ip in range(int(TELEGRAM_IP_LOWER), int(TELEGRAM_IP_UPPER) + 1))) +allow_ip(TELEGRAM_SUBNET_1, TELEGRAM_SUBNET_2) class WebhookRequestHandler(web.View): @@ -69,7 +77,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' @@ -165,7 +173,7 @@ class WebhookRequestHandler(web.View): timeout_handle = loop.call_later(RESPONSE_TIMEOUT, asyncio.tasks._release_waiter, waiter) cb = functools.partial(asyncio.tasks._release_waiter, waiter) - fut = asyncio.ensure_future(dispatcher.process_update(update), loop=loop) + fut = asyncio.ensure_future(dispatcher.updates_handler.notify(update), loop=loop) fut.add_done_callback(cb) try: @@ -219,7 +227,7 @@ class WebhookRequestHandler(web.View): """ if results is None: return None - for result in results: + for result in itertools.chain.from_iterable(results): if isinstance(result, BaseResponse): return result diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 5416afd9..5395e486 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -33,6 +33,7 @@ from .input_message_content import InputContactMessageContent, InputLocationMess from .invoice import Invoice from .labeled_price import LabeledPrice from .location import Location +from .login_url import LoginUrl from .mask_position import MaskPosition from .message import ContentType, ContentTypes, Message, ParseMode from .message_entity import MessageEntity, MessageEntityType @@ -43,6 +44,7 @@ from .passport_element_error import PassportElementError, PassportElementErrorDa PassportElementErrorSelfie from .passport_file import PassportFile from .photo_size import PhotoSize +from .poll import PollOption, Poll from .pre_checkout_query import PreCheckoutQuery from .reply_keyboard import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove from .response_parameters import ResponseParameters @@ -125,6 +127,7 @@ __all__ = ( 'KeyboardButton', 'LabeledPrice', 'Location', + 'LoginUrl', 'MaskPosition', 'MediaGroup', 'Message', @@ -142,6 +145,8 @@ __all__ = ( 'PassportElementErrorSelfie', 'PassportFile', 'PhotoSize', + 'Poll', + 'PollOption', 'PreCheckoutQuery', 'ReplyKeyboardMarkup', 'ReplyKeyboardRemove', diff --git a/aiogram/types/base.py b/aiogram/types/base.py index 8125a37d..97f67b16 100644 --- a/aiogram/types/base.py +++ b/aiogram/types/base.py @@ -4,6 +4,8 @@ import io import typing from typing import TypeVar +from babel.support import LazyProxy + from .fields import BaseField from ..utils import json from ..utils.mixins import ContextInstanceMixin @@ -142,7 +144,13 @@ class TelegramObject(ContextInstanceMixin, metaclass=MetaTelegramObject): @property def bot(self): from ..bot.bot import Bot - return Bot.get_current() + + bot = Bot.get_current() + if bot is None: + raise RuntimeError("Can't get bot instance from context. " + "You can fix it with setting current instance: " + "'Bot.set_current(bot_instance)'") + return bot def to_python(self) -> typing.Dict: """ @@ -157,6 +165,8 @@ class TelegramObject(ContextInstanceMixin, metaclass=MetaTelegramObject): value = self.props[name].export(self) if isinstance(value, TelegramObject): value = value.to_python() + if isinstance(value, LazyProxy): + value = str(value) result[self.props_aliases.get(name, name)] = value return result diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 321d77db..12789462 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,4 +1,5 @@ import datetime +import warnings from . import base from . import fields @@ -24,15 +25,22 @@ class ChatMember(base.TelegramObject): can_restrict_members: base.Boolean = fields.Field() can_pin_messages: base.Boolean = fields.Field() can_promote_members: 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_other_messages: base.Boolean = fields.Field() can_add_web_page_previews: base.Boolean = fields.Field() def is_admin(self): + warnings.warn('`is_admin` method deprecated due to updates in Bot API 4.2. ' + 'This method renamed to `is_chat_admin` and will be available until aiogram 2.3', + DeprecationWarning, stacklevel=2) + return self.is_chat_admin() + + def is_chat_admin(self): return ChatMemberStatus.is_admin(self.status) - def is_member(self): + def is_chat_member(self): return ChatMemberStatus.is_member(self.status) def __int__(self): @@ -54,8 +62,22 @@ class ChatMemberStatus(helper.Helper): @classmethod def is_admin(cls, role): - return role in [cls.ADMINISTRATOR, cls.CREATOR] + warnings.warn('`is_admin` method deprecated due to updates in Bot API 4.2. ' + 'This method renamed to `is_chat_admin` and will be available until aiogram 2.3', + DeprecationWarning, stacklevel=2) + return cls.is_chat_admin(role) @classmethod def is_member(cls, role): + warnings.warn('`is_member` method deprecated due to updates in Bot API 4.2. ' + 'This method renamed to `is_chat_member` and will be available until aiogram 2.3', + DeprecationWarning, stacklevel=2) + return cls.is_chat_member(role) + + @classmethod + def is_chat_admin(cls, role): + return role in [cls.ADMINISTRATOR, cls.CREATOR] + + @classmethod + def is_chat_member(cls, role): return role in [cls.MEMBER, cls.ADMINISTRATOR, cls.CREATOR] diff --git a/aiogram/types/encrypted_passport_element.py b/aiogram/types/encrypted_passport_element.py index bc7b212b..76e02ec1 100644 --- a/aiogram/types/encrypted_passport_element.py +++ b/aiogram/types/encrypted_passport_element.py @@ -1,6 +1,7 @@ +import typing + from . import base from . import fields -import typing from .passport_file import PassportFile diff --git a/aiogram/types/inline_keyboard.py b/aiogram/types/inline_keyboard.py index 69049345..97ad35da 100644 --- a/aiogram/types/inline_keyboard.py +++ b/aiogram/types/inline_keyboard.py @@ -3,6 +3,7 @@ import typing from . import base from . import fields from .callback_game import CallbackGame +from .login_url import LoginUrl class InlineKeyboardMarkup(base.TelegramObject): @@ -16,10 +17,16 @@ class InlineKeyboardMarkup(base.TelegramObject): """ inline_keyboard: 'typing.List[typing.List[InlineKeyboardButton]]' = fields.ListOfLists(base='InlineKeyboardButton') - def __init__(self, row_width=3, inline_keyboard=None): + def __init__(self, row_width=3, inline_keyboard=None, **kwargs): if inline_keyboard is None: inline_keyboard = [] - super(InlineKeyboardMarkup, self).__init__(conf={'row_width': row_width}, inline_keyboard=inline_keyboard) + + conf = kwargs.pop('conf', {}) or {} + conf['row_width'] = row_width + + super(InlineKeyboardMarkup, self).__init__(**kwargs, + conf=conf, + inline_keyboard=inline_keyboard) @property def row_width(self): @@ -84,16 +91,26 @@ class InlineKeyboardButton(base.TelegramObject): """ text: base.String = fields.Field() url: base.String = fields.Field() + login_url: LoginUrl = fields.Field(base=LoginUrl) callback_data: base.String = fields.Field() switch_inline_query: base.String = fields.Field() switch_inline_query_current_chat: base.String = fields.Field() callback_game: CallbackGame = fields.Field(base=CallbackGame) pay: base.Boolean = fields.Field() - def __init__(self, text: base.String, url: base.String = None, callback_data: base.String = None, - switch_inline_query: base.String = None, switch_inline_query_current_chat: base.String = None, - callback_game: CallbackGame = None, pay: base.Boolean = None): - super(InlineKeyboardButton, self).__init__(text=text, url=url, callback_data=callback_data, + def __init__(self, text: base.String, + url: base.String = None, + login_url: LoginUrl = None, + callback_data: base.String = None, + switch_inline_query: base.String = None, + switch_inline_query_current_chat: base.String = None, + callback_game: CallbackGame = None, + pay: base.Boolean = None, **kwargs): + super(InlineKeyboardButton, self).__init__(text=text, + url=url, + login_url=login_url, + callback_data=callback_data, switch_inline_query=switch_inline_query, switch_inline_query_current_chat=switch_inline_query_current_chat, - callback_game=callback_game, pay=pay) + callback_game=callback_game, + pay=pay, **kwargs) diff --git a/aiogram/types/login_url.py b/aiogram/types/login_url.py new file mode 100644 index 00000000..c0dd6133 --- /dev/null +++ b/aiogram/types/login_url.py @@ -0,0 +1,30 @@ +from . import base +from . import fields + + +class LoginUrl(base.TelegramObject): + """ + This object represents a parameter of the inline keyboard button used to automatically authorize a user. + Serves as a great replacement for the Telegram Login Widget when the user is coming from Telegram. + All the user needs to do is tap/click a button and confirm that they want to log in. + + https://core.telegram.org/bots/api#loginurl + """ + url: base.String = fields.Field() + forward_text: base.String = fields.Field() + bot_username: base.String = fields.Field() + request_write_access: base.Boolean = fields.Field() + + def __init__(self, + url: base.String, + forward_text: base.String = None, + bot_username: base.String = None, + request_write_access: base.Boolean = None, + **kwargs): + super(LoginUrl, self).__init__( + url=url, + forward_text=forward_text, + bot_username=bot_username, + request_write_access=request_write_access, + **kwargs + ) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 4f5e0914..7637cf42 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -12,13 +12,17 @@ from .audio import Audio from .chat import Chat, ChatType from .contact import Contact from .document import Document +from .force_reply import ForceReply from .game import Game -from .input_media import MediaGroup +from .inline_keyboard import InlineKeyboardMarkup +from .input_media import MediaGroup, InputMedia from .invoice import Invoice from .location import Location from .message_entity import MessageEntity from .passport_data import PassportData from .photo_size import PhotoSize +from .poll import Poll +from .reply_keyboard import ReplyKeyboardRemove, ReplyKeyboardMarkup from .sticker import Sticker from .successful_payment import SuccessfulPayment from .user import User @@ -28,6 +32,7 @@ from .video_note import VideoNote from .voice import Voice from ..utils import helper from ..utils import markdown as md +from ..utils.deprecated import warn_deprecated class Message(base.TelegramObject): @@ -49,6 +54,7 @@ class Message(base.TelegramObject): edit_date: datetime.datetime = fields.DateTimeField() media_group_id: base.String = fields.Field() author_signature: base.String = fields.Field() + forward_sender_name: base.String = fields.Field() text: base.String = fields.Field() entities: typing.List[MessageEntity] = fields.ListField(base=MessageEntity) caption_entities: typing.List[MessageEntity] = fields.ListField(base=MessageEntity) @@ -80,6 +86,8 @@ class Message(base.TelegramObject): successful_payment: SuccessfulPayment = fields.Field(base=SuccessfulPayment) connected_website: base.String = fields.Field() passport_data: PassportData = fields.Field(base=PassportData) + poll: Poll = fields.Field(base=Poll) + reply_markup: InlineKeyboardMarkup = fields.Field(base=InlineKeyboardMarkup) @property @functools.lru_cache() @@ -136,6 +144,8 @@ class Message(base.TelegramObject): return ContentType.GROUP_CHAT_CREATED elif self.passport_data: return ContentType.PASSPORT_DATA + elif self.poll: + return ContentType.POLL else: return ContentType.UNKNOWN @@ -193,7 +203,8 @@ class Message(base.TelegramObject): quote_fn = md.quote_html if as_html else md.escape_md - if not self.entities: + entities = self.entities or self.caption_entities + if not entities: return quote_fn(text) if not sys.maxunicode == 0xffff: @@ -202,7 +213,7 @@ class Message(base.TelegramObject): result = '' offset = 0 - for entity in sorted(self.entities, key=lambda item: item.offset): + for entity in sorted(entities, key=lambda item: item.offset): entity_text = entity.parse(text, as_html=as_html) if sys.maxunicode == 0xffff: @@ -274,61 +285,96 @@ class Message(base.TelegramObject): return md.hlink(text, url) return md.link(text, url) - async def reply(self, text, parse_mode=None, disable_web_page_preview=None, - disable_notification=None, reply_markup=None, reply=True) -> Message: + async def answer(self, text: base.String, + parse_mode: typing.Union[base.String, None] = None, + disable_web_page_preview: typing.Union[base.Boolean, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = False) -> Message: """ - Reply to this message + Answer to this message - :param text: str - :param parse_mode: str - :param disable_web_page_preview: bool - :param disable_notification: bool - :param reply_markup: + :param text: Text of the message to be sent + :type text: :obj:`base.String` + :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, + fixed-width text or inline URLs in your bot's message. + :type parse_mode: :obj:`typing.Union[base.String, None]` + :param disable_web_page_preview: Disables link previews for links in this message + :type disable_web_page_preview: :obj:`typing.Union[base.Boolean, 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: :class:`aiogram.types.Message` + :type reply: :obj:`base.Boolean` + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` """ - return await self.bot.send_message(chat_id=self.chat.id, text=text, + return await self.bot.send_message(chat_id=self.chat.id, + text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def reply_photo(self, photo: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, reply=True) -> Message: + async def answer_photo(self, photo: typing.Union[base.InputFile, base.String], + 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 = False) -> Message: """ Use this method to send photos. Source: https://core.telegram.org/bots/api#sendphoto - :param photo: Photo to send. + :param photo: Photo to send :type photo: :obj:`typing.Union[base.InputFile, base.String]` - :param caption: Photo caption (may also be used when resending photos by file_id), 0-200 characters + :param caption: Photo caption (may also be used when resending photos by file_id), 0-1024 characters :type caption: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, + fixed-width text or inline URLs in your bot's message. + :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. + :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. + :type reply: :obj:`base.Boolean` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_photo(chat_id=self.chat.id, photo=photo, caption=caption, + return await self.bot.send_photo(chat_id=self.chat.id, + photo=photo, + 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_audio(self, audio: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - performer: typing.Union[base.String, None] = None, - title: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_audio(self, audio: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + performer: typing.Union[base.String, None] = None, + title: 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 = False) -> Message: """ Use this method to send audio files, if you want Telegram clients to display them in the music player. Your audio must be in the .mp3 format. @@ -341,6 +387,9 @@ class Message(base.TelegramObject): :type audio: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Audio caption, 0-200 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 your bot's message. + :type parse_mode: :obj:`typing.Union[base.String, None]` :param duration: Duration of the audio in seconds :type duration: :obj:`typing.Union[base.Integer, None]` :param performer: Performer @@ -349,7 +398,8 @@ class Message(base.TelegramObject): :type title: :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. + :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' @@ -359,6 +409,7 @@ class Message(base.TelegramObject): return await self.bot.send_audio(chat_id=self.chat.id, audio=audio, caption=caption, + parse_mode=parse_mode, duration=duration, performer=performer, title=title, @@ -366,17 +417,19 @@ 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=None, - reply=True) -> Message: + async def answer_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 = False) -> Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -413,7 +466,8 @@ class Message(base.TelegramObject): :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ - return await self.bot.send_animation(self.chat.id, animation=animation, + return await self.bot.send_animation(self.chat.id, + animation=animation, duration=duration, width=width, height=height, @@ -422,14 +476,17 @@ class Message(base.TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup - ) + reply_markup=reply_markup) - async def reply_document(self, document: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_document(self, document: typing.Union[base.InputFile, base.String], + 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 = False) -> Message: """ Use this method to send general files. @@ -441,9 +498,13 @@ class Message(base.TelegramObject): :type document: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Document caption (may also be used when resending documents by file_id), 0-200 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. + :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' @@ -453,18 +514,23 @@ class Message(base.TelegramObject): return await self.bot.send_document(chat_id=self.chat.id, document=document, 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_video(self, video: 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, - caption: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_video(self, video: 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, + 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 = False) -> Message: """ Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -481,9 +547,13 @@ class Message(base.TelegramObject): :type height: :obj:`typing.Union[base.Integer, None]` :param caption: Video caption (may also be used when resending videos by file_id), 0-200 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. + :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' @@ -496,16 +566,21 @@ class Message(base.TelegramObject): width=width, height=height, 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_voice(self, voice: typing.Union[base.InputFile, base.String], - caption: typing.Union[base.String, None] = None, - duration: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_voice(self, voice: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = False) -> Message: """ Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. @@ -519,11 +594,15 @@ class Message(base.TelegramObject): :type voice: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Voice message caption, 0-200 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 duration: Duration of the voice message in seconds :type duration: :obj:`typing.Union[base.Integer, 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. + :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' @@ -533,17 +612,21 @@ class Message(base.TelegramObject): return await self.bot.send_voice(chat_id=self.chat.id, voice=voice, caption=caption, + parse_mode=parse_mode, duration=duration, disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def reply_video_note(self, video_note: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - length: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_video_note(self, video_note: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + length: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = False) -> Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. Use this method to send video messages. @@ -558,7 +641,8 @@ class Message(base.TelegramObject): :type length: :obj:`typing.Union[base.Integer, 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. + :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' @@ -573,9 +657,9 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def reply_media_group(self, media: typing.Union[MediaGroup, typing.List], - disable_notification: typing.Union[base.Boolean, None] = None, - reply=True) -> typing.List[Message]: + async def answer_media_group(self, media: typing.Union[MediaGroup, typing.List], + disable_notification: typing.Union[base.Boolean, None] = None, + reply: base.Boolean = False) -> typing.List[Message]: """ Use this method to send a group of photos or videos as an album. @@ -594,11 +678,15 @@ class Message(base.TelegramObject): disable_notification=disable_notification, reply_to_message_id=self.message_id if reply else None) - async def reply_location(self, latitude: base.Float, - longitude: base.Float, live_period: typing.Union[base.Integer, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, - reply=True) -> Message: + async def answer_location(self, + latitude: base.Float, longitude: base.Float, + live_period: typing.Union[base.Integer, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = False) -> Message: """ Use this method to send point on the map. @@ -612,7 +700,8 @@ class Message(base.TelegramObject): :type live_period: :obj:`typing.Union[base.Integer, 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. + :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' @@ -627,50 +716,16 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def edit_live_location(self, latitude: base.Float, longitude: base.Float, - reply_markup=None) -> typing.Union[Message, base.Boolean]: - """ - Use this method to edit live location messages sent by the bot or via the bot (for inline bots). - A location can be edited until its live_period expires or editing is explicitly disabled by a call - to stopMessageLiveLocation. - - Source: https://core.telegram.org/bots/api#editmessagelivelocation - - :param latitude: Latitude of new location - :type latitude: :obj:`base.Float` - :param longitude: Longitude of new location - :type longitude: :obj:`base.Float` - :param reply_markup: A JSON-serialized object for a new inline keyboard. - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` - :return: On success, if the edited message was sent by the bot, the edited Message is returned, - otherwise True is returned. - :rtype: :obj:`typing.Union[types.Message, base.Boolean]` - """ - return await self.bot.edit_message_live_location(latitude=latitude, longitude=longitude, - chat_id=self.chat.id, message_id=self.message_id, - reply_markup=reply_markup) - - async def stop_live_location(self, reply_markup=None) -> typing.Union[Message, base.Boolean]: - """ - Use this method to stop updating a live location message sent by the bot or via the bot - (for inline bots) before live_period expires. - - Source: https://core.telegram.org/bots/api#stopmessagelivelocation - - :param reply_markup: A JSON-serialized object for a new inline keyboard. - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` - :return: On success, if the message was sent by the bot, the sent Message is returned, - otherwise True is returned. - :rtype: :obj:`typing.Union[types.Message, base.Boolean]` - """ - return await self.bot.stop_message_live_location(chat_id=self.chat.id, message_id=self.message_id, - 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=None, - reply=True) -> Message: + async def answer_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 = False) -> Message: """ Use this method to send information about a venue. @@ -688,7 +743,664 @@ class Message(base.TelegramObject): :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. + :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` + """ + 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 answer_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 = False) -> 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` + """ + 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 answer_sticker(self, sticker: typing.Union[base.InputFile, base.String], + disable_notification: typing.Union[base.Boolean, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = False) -> Message: + """ + Use this method to send .webp stickers. + + Source: https://core.telegram.org/bots/api#sendsticker + + :param sticker: Sticker to send. + :type sticker: :obj:`typing.Union[base.InputFile, base.String]` + :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` + """ + return await self.bot.send_sticker(chat_id=self.chat.id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup) + + async def reply(self, text: base.String, + parse_mode: typing.Union[base.String, None] = None, + disable_web_page_preview: typing.Union[base.Boolean, 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: + """ + Reply to this message + + :param text: Text of the message to be sent + :type text: :obj:`base.String` + :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, + fixed-width text or inline URLs in your bot's message. + :type parse_mode: :obj:`typing.Union[base.String, None]` + :param disable_web_page_preview: Disables link previews for links in this message + :type disable_web_page_preview: :obj:`typing.Union[base.Boolean, 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' + :type reply: :obj:`base.Boolean` + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` + """ + return await self.bot.send_message(chat_id=self.chat.id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup) + + async def reply_photo(self, photo: typing.Union[base.InputFile, base.String], + 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 photos. + + Source: https://core.telegram.org/bots/api#sendphoto + + :param photo: Photo to send + :type photo: :obj:`typing.Union[base.InputFile, base.String]` + :param caption: Photo caption (may also be used when resending photos 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 your bot's message. + :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[types.InlineKeyboardMarkup, + types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` + :param reply: fill 'reply_to_message_id' + :type reply: :obj:`base.Boolean` + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` + """ + return await self.bot.send_photo(chat_id=self.chat.id, + photo=photo, + 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_audio(self, audio: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, None] = None, + performer: typing.Union[base.String, None] = None, + title: 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 audio files, if you want Telegram clients to display them in the music player. + Your audio must be in the .mp3 format. + + For sending voice messages, use the sendVoice method instead. + + Source: https://core.telegram.org/bots/api#sendaudio + + :param audio: Audio file to send. + :type audio: :obj:`typing.Union[base.InputFile, base.String]` + :param caption: Audio caption, 0-200 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 your bot's message. + :type parse_mode: :obj:`typing.Union[base.String, None]` + :param duration: Duration of the audio in seconds + :type duration: :obj:`typing.Union[base.Integer, None]` + :param performer: Performer + :type performer: :obj:`typing.Union[base.String, None]` + :param title: Track name + :type title: :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` + """ + return await self.bot.send_audio(chat_id=self.chat.id, + audio=audio, + caption=caption, + parse_mode=parse_mode, + duration=duration, + performer=performer, + title=title, + disable_notification=disable_notification, + 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, + 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` + """ + 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_document(self, document: typing.Union[base.InputFile, base.String], + 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 general files. + + Bots can currently send files of any type of up to 50 MB in size, this limit may be changed in the future. + + Source: https://core.telegram.org/bots/api#senddocument + + :param document: File to send. + :type document: :obj:`typing.Union[base.InputFile, base.String]` + :param caption: Document caption (may also be used when resending documents by file_id), 0-200 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[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` + """ + return await self.bot.send_document(chat_id=self.chat.id, + document=document, + 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_video(self, video: 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, + 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 video files, Telegram clients support mp4 videos + (other formats may be sent as Document). + + Source: https://core.telegram.org/bots/api#sendvideo + + :param video: Video to send. + :type video: :obj:`typing.Union[base.InputFile, base.String]` + :param duration: Duration of sent video in seconds + :type duration: :obj:`typing.Union[base.Integer, None]` + :param width: Video width + :type width: :obj:`typing.Union[base.Integer, None]` + :param height: Video height + :type height: :obj:`typing.Union[base.Integer, None]` + :param caption: Video caption (may also be used when resending videos by file_id), 0-200 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[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` + """ + return await self.bot.send_video(chat_id=self.chat.id, + video=video, + duration=duration, + width=width, + height=height, + 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_voice(self, voice: typing.Union[base.InputFile, base.String], + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + duration: typing.Union[base.Integer, 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 audio files, if you want Telegram clients to display the file + as a playable voice message. + + For this to work, your audio must be in an .ogg file encoded with OPUS + (other formats may be sent as Audio or Document). + + Source: https://core.telegram.org/bots/api#sendvoice + + :param voice: Audio file to send. + :type voice: :obj:`typing.Union[base.InputFile, base.String]` + :param caption: Voice message caption, 0-200 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 duration: Duration of the voice message in seconds + :type duration: :obj:`typing.Union[base.Integer, 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` + """ + return await self.bot.send_voice(chat_id=self.chat.id, + voice=voice, + caption=caption, + parse_mode=parse_mode, + duration=duration, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup) + + async def reply_video_note(self, video_note: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + length: typing.Union[base.Integer, 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: + """ + As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. + Use this method to send video messages. + + Source: https://core.telegram.org/bots/api#sendvideonote + + :param video_note: Video note to send. + :type video_note: :obj:`typing.Union[base.InputFile, base.String]` + :param duration: Duration of sent video in seconds + :type duration: :obj:`typing.Union[base.Integer, None]` + :param length: Video width and height + :type length: :obj:`typing.Union[base.Integer, 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` + """ + return await self.bot.send_video_note(chat_id=self.chat.id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup) + + async def reply_media_group(self, media: typing.Union[MediaGroup, typing.List], + disable_notification: typing.Union[base.Boolean, None] = None, + reply: base.Boolean = True) -> typing.List[Message]: + """ + Use this method to send a group of photos or videos as an album. + + Source: https://core.telegram.org/bots/api#sendmediagroup + + :param media: A JSON-serialized array describing photos and videos to be sent + :type media: :obj:`typing.Union[types.MediaGroup, typing.List]` + :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: fill 'reply_to_message_id' + :return: On success, an array of the sent Messages is returned. + :rtype: typing.List[types.Message] + """ + return await self.bot.send_media_group(self.chat.id, + media=media, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None) + + async def reply_location(self, + latitude: base.Float, longitude: base.Float, + live_period: typing.Union[base.Integer, 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 point on the map. + + Source: https://core.telegram.org/bots/api#sendlocation + + :param latitude: Latitude of the location + :type latitude: :obj:`base.Float` + :param longitude: Longitude of the location + :type longitude: :obj:`base.Float` + :param live_period: Period in seconds for which the location will be updated + :type live_period: :obj:`typing.Union[base.Integer, 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` + """ + return await self.bot.send_location(chat_id=self.chat.id, + latitude=latitude, + longitude=longitude, + live_period=live_period, + disable_notification=disable_notification, + 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, + 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' @@ -708,8 +1420,11 @@ class Message(base.TelegramObject): 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=None, - reply=True) -> Message: + reply_markup: typing.Union[InlineKeyboardMarkup, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, None] = None, + reply: base.Boolean = True) -> Message: """ Use this method to send phone contacts. @@ -723,7 +1438,48 @@ class Message(base.TelegramObject): :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. + :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, + 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' @@ -737,20 +1493,57 @@ class Message(base.TelegramObject): reply_to_message_id=self.message_id if reply else None, reply_markup=reply_markup) - async def forward(self, chat_id, disable_notification=None) -> Message: + async def reply_sticker(self, sticker: typing.Union[base.InputFile, base.String], + 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 .webp stickers. + + Source: https://core.telegram.org/bots/api#sendsticker + + :param sticker: Sticker to send. + :type sticker: :obj:`typing.Union[base.InputFile, base.String]` + :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` + """ + return await self.bot.send_sticker(chat_id=self.chat.id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=self.message_id if reply else None, + reply_markup=reply_markup) + + async def forward(self, chat_id: typing.Union[base.Integer, base.String], + disable_notification: typing.Union[base.Boolean, None] = None) -> Message: """ Forward this message - :param chat_id: - :param disable_notification: - :return: + Source: https://core.telegram.org/bots/api#forwardmessage + + :param chat_id: Unique identifier for the target chat or username of the target channel + :type chat_id: :obj:`typing.Union[base.Integer, base.String]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound + :type disable_notification: :obj:`typing.Union[base.Boolean, None]` + :return: On success, the sent Message is returned + :rtype: :obj:`types.Message` """ return await self.bot.forward_message(chat_id, self.chat.id, self.message_id, disable_notification) async def edit_text(self, text: base.String, parse_mode: typing.Union[base.String, None] = None, disable_web_page_preview: typing.Union[base.Boolean, None] = None, - reply_markup=None): + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: """ Use this method to edit text and game messages sent by the bot or via the bot (for inline bots). @@ -775,44 +1568,154 @@ class Message(base.TelegramObject): disable_web_page_preview=disable_web_page_preview, reply_markup=reply_markup) - async def delete(self): + async def edit_caption(self, caption: base.String, + parse_mode: typing.Union[base.String, None] = None, + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: """ - Delete this message + Use this method to edit captions of messages sent by the bot or via the bot (for inline bots). - :return: bool + Source: https://core.telegram.org/bots/api#editmessagecaption + + :param caption: New caption of the message + :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 your bot's message. + :type parse_mode: :obj:`typing.Union[base.String, None]` + :param reply_markup: A JSON-serialized object for an inline keyboard + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, if edited message is sent by the bot, the edited Message is returned, + otherwise True is returned. + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.edit_message_caption(chat_id=self.chat.id, message_id=self.message_id, caption=caption, + parse_mode=parse_mode, reply_markup=reply_markup) + + async def edit_media(self, media: InputMedia, + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: + """ + Use this method to edit audio, document, photo, or video messages. + If a message is a part of a message album, then it can be edited only to a photo or a video. + Otherwise, message type can be changed arbitrarily. + When inline message is edited, new file can't be uploaded. + Use previously uploaded file via its file_id or specify a URL. + + On success, if the edited message was sent by the bot, + the edited Message is returned, otherwise True is returned. + + Source https://core.telegram.org/bots/api#editmessagemedia + + :param media: A JSON-serialized object for a new media content of the message + :type media: :obj:`types.InputMedia` + :param reply_markup: A JSON-serialized object for a new inline keyboard + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, if the edited message was sent by the bot, the edited Message is returned, + otherwise True is returned + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.edit_message_media(media=media, chat_id=self.chat.id, message_id=self.message_id, + reply_markup=reply_markup) + + async def edit_reply_markup(self, + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: + """ + Use this method to edit only the reply markup of messages sent by the bot or via the bot (for inline bots). + + Source: https://core.telegram.org/bots/api#editmessagereplymarkup + + :param reply_markup: A JSON-serialized object for an inline keyboard + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, if edited message is sent by the bot, the edited Message is returned, + otherwise True is returned. + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.edit_message_reply_markup(chat_id=self.chat.id, message_id=self.message_id, + reply_markup=reply_markup) + + async def delete_reply_markup(self) -> typing.Union[Message, base.Boolean]: + """ + Use this method to delete reply markup of messages sent by the bot or via the bot (for inline bots). + + :return: On success, if edited message is sent by the bot, the edited Message is returned, + otherwise True is returned. + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.edit_message_reply_markup(chat_id=self.chat.id, message_id=self.message_id) + + async def edit_live_location(self, latitude: base.Float, + longitude: base.Float, + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: + """ + Use this method to edit live location messages sent by the bot or via the bot (for inline bots). + A location can be edited until its live_period expires or editing is explicitly disabled by a call + to stopMessageLiveLocation. + + Source: https://core.telegram.org/bots/api#editmessagelivelocation + + :param latitude: Latitude of new location + :type latitude: :obj:`base.Float` + :param longitude: Longitude of new location + :type longitude: :obj:`base.Float` + :param reply_markup: A JSON-serialized object for a new inline keyboard. + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, if the edited message was sent by the bot, the edited Message is returned, + otherwise True is returned. + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.edit_message_live_location(latitude=latitude, longitude=longitude, + chat_id=self.chat.id, message_id=self.message_id, + reply_markup=reply_markup) + + async def stop_live_location(self, + reply_markup: typing.Union[InlineKeyboardMarkup, + None] = None) -> typing.Union[Message, base.Boolean]: + """ + Use this method to stop updating a live location message sent by the bot or via the bot + (for inline bots) before live_period expires. + + Source: https://core.telegram.org/bots/api#stopmessagelivelocation + + :param reply_markup: A JSON-serialized object for a new inline keyboard. + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` + :return: On success, if the message was sent by the bot, the sent Message is returned, + otherwise True is returned. + :rtype: :obj:`typing.Union[types.Message, base.Boolean]` + """ + return await self.bot.stop_message_live_location(chat_id=self.chat.id, message_id=self.message_id, + reply_markup=reply_markup) + + async def delete(self) -> base.Boolean: + """ + Use this method to delete a message, including service messages, with the following limitations: + - A message can only be deleted if it was sent less than 48 hours ago. + - Bots can delete outgoing messages in private chats, groups, and supergroups. + - Bots can delete incoming messages in private chats. + - Bots granted can_post_messages permissions can delete outgoing messages in channels. + - If the bot is an administrator of a group, it can delete any message there. + - If the bot has can_delete_messages permission in a supergroup or a channel, it can delete any message there. + + Source: https://core.telegram.org/bots/api#deletemessage + + :return: Returns True on success + :rtype: :obj:`base.Boolean` """ return await self.bot.delete_message(self.chat.id, self.message_id) - async def reply_sticker(self, sticker: typing.Union[base.InputFile, base.String], - disable_notification: typing.Union[base.Boolean, None] = None, - reply_markup=None, reply=True) -> Message: + async def pin(self, disable_notification: typing.Union[base.Boolean, None] = None) -> base.Boolean: """ - Use this method to send .webp stickers. + Use this method to pin a message in a supergroup. + 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#sendsticker + Source: https://core.telegram.org/bots/api#pinchatmessage - :param sticker: Sticker to send. - :type sticker: :obj:`typing.Union[base.InputFile, base.String]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Pass True, if it is not necessary to send a notification to + all group members about the new pinned message :type disable_notification: :obj:`typing.Union[base.Boolean, None]` - :param reply_markup: Additional interface options. - :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` - """ - return await self.bot.send_sticker(chat_id=self.chat.id, sticker=sticker, - disable_notification=disable_notification, - reply_to_message_id=self.message_id if reply else None, - reply_markup=reply_markup) - - async def pin(self, disable_notification: bool = False): - """ - Pin message - - :param disable_notification: - :return: + :return: Returns True on success + :rtype: :obj:`base.Boolean` """ return await self.chat.pin_message(self.message_id, disable_notification) @@ -876,6 +1779,7 @@ class ContentType(helper.Helper): DELETE_CHAT_PHOTO = helper.Item() # delete_chat_photo GROUP_CHAT_CREATED = helper.Item() # group_chat_created PASSPORT_DATA = helper.Item() # passport_data + POLL = helper.Item() UNKNOWN = helper.Item() # unknown ANY = helper.Item() # any @@ -937,6 +1841,7 @@ class ContentTypes(helper.Helper): DELETE_CHAT_PHOTO = helper.ListItem() # delete_chat_photo GROUP_CHAT_CREATED = helper.ListItem() # group_chat_created PASSPORT_DATA = helper.ListItem() # passport_data + POLL = helper.ListItem() UNKNOWN = helper.ListItem() # unknown ANY = helper.ListItem() # any diff --git a/aiogram/types/mixins.py b/aiogram/types/mixins.py index 396633ad..13f8412f 100644 --- a/aiogram/types/mixins.py +++ b/aiogram/types/mixins.py @@ -24,7 +24,7 @@ class Downloadable: if destination is None: destination = file.file_path elif isinstance(destination, (str, pathlib.Path)) and os.path.isdir(destination): - os.path.join(destination, file.file_path) + destination = os.path.join(destination, file.file_path) else: is_path = False @@ -45,5 +45,18 @@ class Downloadable: else: return await self.bot.get_file(self.file_id) + async def get_url(self): + """ + Get file url. + + Attention!! + This method has security vulnerabilities for the reason that result + contains bot's *access token* in open form. Use at your own risk! + + :return: url + """ + file = await self.get_file() + return self.bot.get_file_url(file.file_path) + def __hash__(self): return hash(self.file_id) diff --git a/aiogram/types/passport_data.py b/aiogram/types/passport_data.py index 06cbad1c..2fed9fae 100644 --- a/aiogram/types/passport_data.py +++ b/aiogram/types/passport_data.py @@ -1,8 +1,9 @@ +import typing + from . import base from . import fields -import typing -from .encrypted_passport_element import EncryptedPassportElement from .encrypted_credentials import EncryptedCredentials +from .encrypted_passport_element import EncryptedPassportElement class PassportData(base.TelegramObject): diff --git a/aiogram/types/poll.py b/aiogram/types/poll.py new file mode 100644 index 00000000..316bca2d --- /dev/null +++ b/aiogram/types/poll.py @@ -0,0 +1,16 @@ +import typing + +from . import base +from . import fields + + +class PollOption(base.TelegramObject): + text: base.String = fields.Field() + voter_count: base.Integer = fields.Field() + + +class Poll(base.TelegramObject): + id: base.String = fields.Field() + question: base.String = fields.Field() + options: typing.List[PollOption] = fields.ListField(base=PollOption) + is_closed: base.Boolean = fields.Field() diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 2753ae5f..9f8ce0fb 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -6,6 +6,7 @@ from .callback_query import CallbackQuery from .chosen_inline_result import ChosenInlineResult from .inline_query import InlineQuery from .message import Message +from .poll import Poll from .pre_checkout_query import PreCheckoutQuery from .shipping_query import ShippingQuery from ..utils import helper @@ -28,6 +29,7 @@ class Update(base.TelegramObject): callback_query: CallbackQuery = fields.Field(base=CallbackQuery) shipping_query: ShippingQuery = fields.Field(base=ShippingQuery) pre_checkout_query: PreCheckoutQuery = fields.Field(base=PreCheckoutQuery) + poll: Poll = fields.Field(base=Poll) def __hash__(self): return self.update_id diff --git a/aiogram/types/venue.py b/aiogram/types/venue.py index 1b420d57..f7b2a277 100644 --- a/aiogram/types/venue.py +++ b/aiogram/types/venue.py @@ -14,4 +14,3 @@ class Venue(base.TelegramObject): address: base.String = fields.Field() foursquare_id: base.String = fields.Field() foursquare_type: base.String = fields.Field() - diff --git a/aiogram/utils/auth_widget.py b/aiogram/utils/auth_widget.py index 8b2eacf7..b9084eb1 100644 --- a/aiogram/utils/auth_widget.py +++ b/aiogram/utils/auth_widget.py @@ -4,11 +4,10 @@ for more information https://core.telegram.org/widgets/login#checking-authorizat Source: https://gist.github.com/JrooTJunior/887791de7273c9df5277d2b1ecadc839 """ +import collections import hashlib import hmac -import collections - def generate_hash(data: dict, token: str) -> str: """ diff --git a/aiogram/utils/callback_data.py b/aiogram/utils/callback_data.py index bb7d6862..916d08c4 100644 --- a/aiogram/utils/callback_data.py +++ b/aiogram/utils/callback_data.py @@ -31,10 +31,8 @@ class CallbackData: raise TypeError(f"Prefix must be instance of str not {type(prefix).__name__}") elif not prefix: raise ValueError('Prefix can\'t be empty') - elif len(sep) != 1: - raise ValueError(f"Length of sep should be equals to 1") elif sep in prefix: - raise ValueError(f"Symbol '{sep}' can't be used in prefix") + raise ValueError(f"Separator '{sep}' can't be used in prefix") elif not parts: raise TypeError('Parts is not passed!') @@ -57,15 +55,16 @@ class CallbackData: for part in self._part_names: value = kwargs.pop(part, None) - if not value: + if value is None: if args: value = args.pop(0) else: raise ValueError(f"Value for '{part}' is not passed!") - if not isinstance(value, str): - raise TypeError(f"Value must be instance of str not {type(value).__name__}") - elif not value: + if value is not None and not isinstance(value, str): + value = str(value) + + if not value: raise ValueError(f"Value for part {part} can't be empty!'") elif self.sep in value: raise ValueError(f"Symbol defined as separator can't be used in values of parts") diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index 9e3bb6d2..a6612547 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -12,11 +12,23 @@ TelegramAPIError MessageCantBeEdited MessageCantBeDeleted MessageToEditNotFound + MessageToReplyNotFound ToMuchMessages + PollError + PollCantBeStopped + PollHasAlreadyClosed + PollsCantBeSentToPrivateChats + PollSizeError + PollMustHaveMoreOptions + PollCantHaveMoreOptions + PollsOptionsLengthTooLong + PollOptionsMustBeNonEmpty + PollQuestionMustBeNonEmpty + MessageWithPollNotFound (with MessageError) + MessageIsNotAPoll (with MessageError) ObjectExpectedAsReplyMarkup InlineKeyboardExpected ChatNotFound - ChatIdIsEmpty ChatDescriptionIsNotModified InvalidQueryID InvalidPeerID @@ -31,14 +43,15 @@ TelegramAPIError WebhookRequireHTTPS BadWebhookPort BadWebhookAddrInfo - CantParseUrl + BadWebhookNoAddressAssociatedWithHostname NotFound MethodNotKnown PhotoAsInputFileRequired InvalidStickersSet NoStickerInRequest ChatAdminRequired - NotEnoughRightsToPinMessage + NeedAdministratorRightsInTheChannel + MethodNotAvailableInPrivateChats CantDemoteChatCreator CantRestrictSelf NotEnoughRightsToRestrict @@ -49,7 +62,9 @@ TelegramAPIError PaymentProviderInvalid CurrencyTotalAmountInvalid CantParseUrl + UnsupportedUrlProtocol CantParseEntities + ResultIdDuplicate ConflictError TerminatedByOtherGetUpdates CantGetUpdates @@ -64,6 +79,10 @@ TelegramAPIError MigrateToChat RestartingTelegram + +TODO: aiogram.utils.exceptions.BadRequest: Bad request: can't parse entities: unsupported start tag "function" at byte offset 0 +TODO: aiogram.utils.exceptions.TelegramAPIError: Gateway Timeout + AIOGramWarning TimeoutWarning """ @@ -71,7 +90,7 @@ import time # TODO: Use exceptions detector from `aiograph`. -_PREFIXES = ['Error: ', '[Error]: ', 'Bad Request: ', 'Conflict: ', 'Not Found: '] +_PREFIXES = ['error: ', '[error]: ', 'bad request: ', 'conflict: ', 'not found: '] def _clean_message(text): @@ -164,6 +183,13 @@ class MessageToDeleteNotFound(MessageError): match = 'message to delete not found' +class MessageToReplyNotFound(MessageError): + """ + Will be raised when you try to reply to very old or deleted or unknown message. + """ + match = 'message to reply not found' + + class MessageIdentifierNotSpecified(MessageError): match = 'message identifier is not specified' @@ -174,7 +200,7 @@ class MessageTextIsEmpty(MessageError): class MessageCantBeEdited(MessageError): match = 'message can\'t be edited' - + class MessageCantBeDeleted(MessageError): match = 'message can\'t be deleted' @@ -203,6 +229,64 @@ class InlineKeyboardExpected(BadRequest): match = 'inline keyboard expected' +class PollError(BadRequest): + __group = True + + +class PollCantBeStopped(PollError): + match = "poll can't be stopped" + + +class PollHasAlreadyBeenClosed(PollError): + match = 'poll has already been closed' + + +class PollsCantBeSentToPrivateChats(PollError): + match = "polls can't be sent to private chats" + + +class PollSizeError(PollError): + __group = True + + +class PollMustHaveMoreOptions(PollSizeError): + match = "poll must have at least 2 option" + + +class PollCantHaveMoreOptions(PollSizeError): + match = "poll can't have more than 10 options" + + +class PollOptionsMustBeNonEmpty(PollSizeError): + match = "poll options must be non-empty" + + +class PollQuestionMustBeNonEmpty(PollSizeError): + match = "poll question must be non-empty" + + +class PollOptionsLengthTooLong(PollSizeError): + match = "poll options length must not exceed 100" + + +class PollQuestionLengthTooLong(PollSizeError): + match = "poll question length must not exceed 255" + + +class MessageWithPollNotFound(PollError, MessageError): + """ + Will be raised when you try to stop poll with message without poll + """ + match = 'message with poll to stop not found' + + +class MessageIsNotAPoll(PollError, MessageError): + """ + Will be raised when you try to stop poll with message without poll + """ + match = 'message is not a poll' + + class ChatNotFound(BadRequest): match = 'chat not found' @@ -211,13 +295,17 @@ class ChatIdIsEmpty(BadRequest): match = 'chat_id is empty' +class InvalidUserId(BadRequest): + match = 'user_id_invalid' + text = 'Invalid user id' + + class ChatDescriptionIsNotModified(BadRequest): match = 'chat description is not modified' class InvalidQueryID(BadRequest): - match = 'QUERY_ID_INVALID' - text = 'Invalid query ID' + match = 'query is too old and response timeout expired or query id is invalid' class InvalidPeerID(BadRequest): @@ -277,10 +365,19 @@ class ChatAdminRequired(BadRequest): text = 'Admin permissions is required!' +class NeedAdministratorRightsInTheChannel(BadRequest): + match = 'need administrator rights in the channel chat' + text = 'Admin permissions is required!' + + class NotEnoughRightsToPinMessage(BadRequest): match = 'not enough rights to pin a message' +class MethodNotAvailableInPrivateChats(BadRequest): + match = 'method is available only for supergroups and channel' + + class CantDemoteChatCreator(BadRequest): match = 'can\'t demote chat creator' @@ -340,14 +437,32 @@ class BadWebhookAddrInfo(BadWebhook): text = 'bad webhook: ' + match +class BadWebhookNoAddressAssociatedWithHostname(BadWebhook): + match = 'failed to resolve host: no address associated with hostname' + + class CantParseUrl(BadRequest): match = 'can\'t parse URL' +class UnsupportedUrlProtocol(BadRequest): + match = 'unsupported URL protocol' + + class CantParseEntities(BadRequest): match = 'can\'t parse entities' +class ResultIdDuplicate(BadRequest): + match = 'result_id_duplicate' + text = 'Result ID duplicate' + + +class BotDomainInvalid(BadRequest): + match = 'bot_domain_invalid' + text = 'Invalid bot domain' + + class NotFound(TelegramAPIError, _MatchErrorMixin): __group = True @@ -375,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): @@ -429,5 +544,5 @@ class Throttled(TelegramAPIError): def __str__(self): return f"Rate limit exceeded! (Limit: {self.rate} s, " \ - f"exceeded: {self.exceeded_count}, " \ - f"time delta: {round(self.delta, 3)} s)" + f"exceeded: {self.exceeded_count}, " \ + f"time delta: {round(self.delta, 3)} s)" diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index 34acf6e9..65594371 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -23,7 +23,7 @@ def _setup_callbacks(executor, on_startup=None, on_shutdown=None): def start_polling(dispatcher, *, loop=None, skip_updates=False, reset_webhook=True, - on_startup=None, on_shutdown=None, timeout=None, fast=True): + on_startup=None, on_shutdown=None, timeout=20, fast=True): """ Start bot in long-polling mode @@ -291,7 +291,7 @@ class Executor: self.set_webhook(webhook_path=webhook_path, request_handler=request_handler, route_name=route_name) self.run_app(**kwargs) - def start_polling(self, reset_webhook=None, timeout=None, fast=True): + def start_polling(self, reset_webhook=None, timeout=20, fast=True): """ Start bot in long-polling mode diff --git a/aiogram/utils/mixins.py b/aiogram/utils/mixins.py index aa248cd9..776479bd 100644 --- a/aiogram/utils/mixins.py +++ b/aiogram/utils/mixins.py @@ -45,4 +45,3 @@ class ContextInstanceMixin: if not isinstance(value, cls): raise TypeError(f"Value should be instance of '{cls.__name__}' not '{type(value).__name__}'") cls.__context_instance.set(value) - diff --git a/aiogram/utils/payload.py b/aiogram/utils/payload.py index bbed1967..45643553 100644 --- a/aiogram/utils/payload.py +++ b/aiogram/utils/payload.py @@ -1,6 +1,8 @@ import datetime import secrets +from babel.support import LazyProxy + from aiogram import types from . import json @@ -57,6 +59,8 @@ def prepare_arg(value): return int((now + value).timestamp()) elif isinstance(value, datetime.datetime): return round(value.timestamp()) + elif isinstance(value, LazyProxy): + return str(value) return value diff --git a/dev_requirements.txt b/dev_requirements.txt index ee1fc5e5..79adc949 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,19 +1,17 @@ -r requirements.txt ujson>=1.35 -python-rapidjson>=0.6.3 -emoji>=0.5.0 -pytest>=3.5.0 -pytest-asyncio>=0.8.0 -tox>=3.0.0 -aresponses>=1.0.0 -uvloop>=0.9.1 -aioredis>=1.1.0 -wheel>=0.31.0 -rethinkdb>=2.3.0 -sphinx>=1.7.3 -sphinx-rtd-theme>=0.3.0 -sphinxcontrib-programoutput>=0.11 -aresponses>=1.0.0 -aiohttp-socks>=0.1.5 -rethinkdb>=2.4.1 \ No newline at end of file +python-rapidjson>=0.7.0 +emoji>=0.5.2 +pytest>=4.4.1,<4.6 +pytest-asyncio>=0.10.0 +tox>=3.9.0 +aresponses>=1.1.1 +uvloop>=0.12.2 +aioredis>=1.2.0 +wheel>=0.31.1 +sphinx>=2.0.1 +sphinx-rtd-theme>=0.4.3 +sphinxcontrib-programoutput>=0.14 +aiohttp-socks>=0.2.2 +rethinkdb>=2.4.1 diff --git a/docs/source/dispatcher/filters.rst b/docs/source/dispatcher/filters.rst index 2615a0f7..d103ac36 100644 --- a/docs/source/dispatcher/filters.rst +++ b/docs/source/dispatcher/filters.rst @@ -4,16 +4,155 @@ Filters Basics ====== -Coming soon... + +Filter factory greatly simplifies the reuse of filters when registering handlers. Filters factory =============== -Coming soon... + +.. autoclass:: aiogram.dispatcher.filters.factory.FiltersFactory + :members: + :show-inheritance: Builtin filters =============== -Coming soon... +``aiogram`` has some builtin filters. Here you can see all of them: + +Command +------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.Command + :members: + :show-inheritance: + +CommandStart +------------ + +.. autoclass:: aiogram.dispatcher.filters.builtin.CommandStart + :members: + :show-inheritance: + +CommandHelp +----------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.CommandHelp + :members: + :show-inheritance: + +CommandSettings +--------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.CommandSettings + :members: + :show-inheritance: + + +CommandPrivacy +-------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.CommandPrivacy + :members: + :show-inheritance: + + +Text +---- + +.. autoclass:: aiogram.dispatcher.filters.builtin.Text + :members: + :show-inheritance: + + +HashTag +------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.HashTag + :members: + :show-inheritance: + + +Regexp +------ + +.. autoclass:: aiogram.dispatcher.filters.builtin.Regexp + :members: + :show-inheritance: + + +RegexpCommandsFilter +-------------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.RegexpCommandsFilter + :members: + :show-inheritance: + + +ContentTypeFilter +----------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.ContentTypeFilter + :members: + :show-inheritance: + + +StateFilter +----------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.StateFilter + :members: + :show-inheritance: + + +ExceptionsFilter +---------------- + +.. autoclass:: aiogram.dispatcher.filters.builtin.ExceptionsFilter + :members: + :show-inheritance: + Making own filters (Custom filters) =================================== -Coming soon... + +Own filter can be: + +- any callable object +- any async function +- any anonymous function (Example: ``lambda msg: msg.text == 'spam'``) +- Subclass of :obj:`AbstractFilter`, :obj:`Filter` or :obj:`BoundFilter` + + +AbstractFilter +-------------- +.. autoclass:: aiogram.dispatcher.filters.filters.AbstractFilter + :members: + :show-inheritance: + +Filter +------ +.. autoclass:: aiogram.dispatcher.filters.filters.Filter + :members: + :show-inheritance: + +BoundFilter +----------- +.. autoclass:: aiogram.dispatcher.filters.filters.BoundFilter + :members: + :show-inheritance: + + +.. code-block:: python + + class ChatIdFilter(BoundFilter): + key = 'chat_id' + + def __init__(self, chat_id: typing.Union[typing.Iterable, int]): + if isinstance(chat_id, int): + chat_id = [chat_id] + self.chat_id = chat_id + + def check(self, message: types.Message) -> bool: + return message.chat.id in self.chat_id + + + dp.filters_factory.bind(ChatIdFilter, event_handlers=[dp.message_handlers]) diff --git a/docs/source/index.rst b/docs/source/index.rst index d9ad1ca4..89cdbf79 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,6 +2,10 @@ Welcome to aiogram's documentation! =================================== + .. image:: https://img.shields.io/badge/telegram-aiogram-blue.svg?style=flat-square + :target: https://t.me/aiogram_live + :alt: [Telegram] aiogram live + .. image:: https://img.shields.io/pypi/v/aiogram.svg?style=flat-square :target: https://pypi.python.org/pypi/aiogram :alt: PyPi Package Version @@ -10,13 +14,17 @@ Welcome to aiogram's documentation! :target: https://pypi.python.org/pypi/aiogram :alt: PyPi status + .. image:: https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square + :target: https://pypi.python.org/pypi/aiogram + :alt: PyPi downloads + .. image:: https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions - .. image:: https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square - :target: https://pypi.python.org/pypi/aiogram - :alt: PyPi downloads + .. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.3-blue.svg?style=flat-square&logo=telegram + :target: https://core.telegram.org/bots/api + :alt: Telegram Bot API .. image:: https://img.shields.io/readthedocs/pip/stable.svg?style=flat-square :target: http://aiogram.readthedocs.io/en/latest/?badge=latest diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 07224d19..b0724a78 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -22,19 +22,19 @@ Next step: interaction with bots starts with one command. Register your first co .. literalinclude:: ../../examples/echo_bot.py :language: python - :lines: 21-25 + :lines: 20-25 If you want to handle all messages in the chat simply add handler without filters: .. literalinclude:: ../../examples/echo_bot.py :language: python - :lines: 28-30 + :lines: 35-37 Last step: run long polling. .. literalinclude:: ../../examples/echo_bot.py :language: python - :lines: 33-34 + :lines: 40-41 Summary ------- diff --git a/examples/callback_data_factory.py b/examples/callback_data_factory.py index d87ae1a3..3dd7d35e 100644 --- a/examples/callback_data_factory.py +++ b/examples/callback_data_factory.py @@ -49,9 +49,9 @@ def get_keyboard() -> types.InlineKeyboardMarkup: def format_post(post_id: str, post: dict) -> (str, types.InlineKeyboardMarkup): text = f"{md.hbold(post['title'])}\n" \ - f"{md.quote_html(post['body'])}\n" \ - f"\n" \ - f"Votes: {post['votes']}" + f"{md.quote_html(post['body'])}\n" \ + f"\n" \ + f"Votes: {post['votes']}" markup = types.InlineKeyboardMarkup() markup.row( diff --git a/examples/finite_state_machine_example.py b/examples/finite_state_machine_example.py index 58b8053c..90ab8aba 100644 --- a/examples/finite_state_machine_example.py +++ b/examples/finite_state_machine_example.py @@ -112,8 +112,8 @@ 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__': diff --git a/examples/i18n_example.py b/examples/i18n_example.py index 6469ed5b..bf23c8d1 100644 --- a/examples/i18n_example.py +++ b/examples/i18n_example.py @@ -3,6 +3,19 @@ Internalize your bot Step 1: extract texts # pybabel extract i18n_example.py -o locales/mybot.pot + + Some useful options: + - Extract texts with pluralization support + # -k __:1,2 + - Add comments for translators, you can use another tag if you want (TR) + # --add-comments=NOTE + - Disable comments with string location in code + # --no-location + - Set project name + # --project=MySuperBot + - Set version + # --version=2.2 + Step 2: create *.po files. For e.g. create en, ru, uk locales. # echo {en,ru,uk} | xargs -n1 pybabel init -i locales/mybot.pot -d locales -D mybot -l Step 3: translate texts @@ -51,6 +64,21 @@ async def cmd_start(message: types.Message): async def cmd_lang(message: types.Message, locale): await message.reply(_('Your current language: {language}').format(language=locale)) +# If you care about pluralization, here's small handler +# And also, there's and example of comments for translators. Most translation tools support them. + +# Alias for gettext method, parser will understand double underscore as plural (aka ngettext) +__ = i18n.gettext + +# Some pseudo numeric value +TOTAL_LIKES = 0 + +@dp.message_handler(commands=['like']) +async def cmd_like(message: types.Message, locale): + TOTAL_LIKES += 1 + + # NOTE: This is comment for a translator + await message.reply(__('Aiogram has {number} like!', 'Aiogram has {number} likes!', TOTAL_LIKES).format(number=TOTAL_LIKES)) if __name__ == '__main__': 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/locales/mybot.pot b/examples/locales/mybot.pot index 988ed463..b0736569 100644 --- a/examples/locales/mybot.pot +++ b/examples/locales/mybot.pot @@ -25,3 +25,7 @@ msgstr "" msgid "Your current language: {language}" msgstr "" +msgid "Aiogram has {number} like!" +msgid_plural "Aiogram has {number} likes!" +msgstr[0] "" +msgstr[1] "" diff --git a/examples/locales/ru/LC_MESSAGES/mybot.po b/examples/locales/ru/LC_MESSAGES/mybot.po index 73876f30..8180af42 100644 --- a/examples/locales/ru/LC_MESSAGES/mybot.po +++ b/examples/locales/ru/LC_MESSAGES/mybot.po @@ -27,3 +27,8 @@ msgstr "Привет, {user}!" msgid "Your current language: {language}" msgstr "Твой язык: {language}" +msgid "Aiogram has {number} like!" +msgid_plural "Aiogram has {number} likes!" +msgstr[0] "Aiogram имеет {number} лайк!" +msgstr[1] "Aiogram имеет {number} лайка!" +msgstr[2] "Aiogram имеет {number} лайков!" \ No newline at end of file diff --git a/examples/payments.py b/examples/payments.py index d85e94ab..e8e37011 100644 --- a/examples/payments.py +++ b/examples/payments.py @@ -2,10 +2,9 @@ import asyncio from aiogram import Bot from aiogram import types -from aiogram.utils import executor from aiogram.dispatcher import Dispatcher from aiogram.types.message import ContentTypes - +from aiogram.utils import executor BOT_TOKEN = 'BOT TOKEN HERE' PAYMENTS_PROVIDER_TOKEN = '123456789:TEST:1234567890abcdef1234567890abcdef' 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/webhook_example.py b/examples/webhook_example.py index fb0046ef..86520988 100644 --- a/examples/webhook_example.py +++ b/examples/webhook_example.py @@ -76,7 +76,8 @@ async def unknown(message: types.Message): """ Handler for unknown messages. """ - return SendMessage(message.chat.id, f"I don\'t know what to do with content type `{message.content_type()}`. Sorry :c") + return SendMessage(message.chat.id, + f"I don\'t know what to do with content type `{message.content_type()}`. Sorry :c") async def cmd_id(message: types.Message): diff --git a/readthedocs.yml b/readthedocs.yml deleted file mode 100644 index 87136e21..00000000 --- a/readthedocs.yml +++ /dev/null @@ -1,5 +0,0 @@ -conda: - file: environment.yml -python: - version: 3 - pip_install: true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4f038772..37d328b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -aiohttp>=3.4.4 +aiohttp>=3.5.4 Babel>=2.6.0 -certifi>=2018.8.24 +certifi>=2019.3.9 diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..262c9395 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,40 @@ +import aresponses + +from aiogram import Bot + +TOKEN = '123456789:AABBCCDDEEFFaabbccddeeff-1234567890' + + +class FakeTelegram(aresponses.ResponsesMockServer): + def __init__(self, message_dict, bot=None, **kwargs): + super().__init__(**kwargs) + self._body, self._headers = self.parse_data(message_dict) + + if isinstance(bot, Bot): + Bot.set_current(bot) + + async def __aenter__(self): + await super().__aenter__() + _response = self.Response(text=self._body, headers=self._headers, status=200, reason='OK') + self.add(self.ANY, response=_response) + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if hasattr(self, 'monkeypatch'): + self.monkeypatch.undo() + await super().__aexit__(exc_type, exc_val, exc_tb) + + @staticmethod + def parse_data(message_dict): + import json + + _body = '{"ok":true,"result":' + json.dumps(message_dict) + '}' + _headers = {'Server': 'nginx/1.12.2', + 'Date': 'Tue, 03 Apr 2018 16:59:54 GMT', + 'Content-Type': 'application/json', + 'Content-Length': str(len(_body)), + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Expose-Headers': 'Content-Length,Content-Type,Date,Server,Connection', + 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains'} + return _body, _headers diff --git a/tests/test_bot.py b/tests/test_bot.py index c3a29687..448f8dda 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -185,7 +185,7 @@ async def test_send_location(bot: Bot, event_loop): @pytest.mark.asyncio -async def test_edit_message_live_location(bot: Bot, event_loop): +async def test_edit_message_live_location_by_bot(bot: Bot, event_loop): """ editMessageLiveLocation method test """ from .types.dataset import MESSAGE_WITH_LOCATION, LOCATION msg = types.Message(**MESSAGE_WITH_LOCATION) @@ -197,6 +197,14 @@ async def test_edit_message_live_location(bot: Bot, event_loop): latitude=location.latitude, longitude=location.longitude) assert result == msg + +@pytest.mark.asyncio +async def test_edit_message_live_location_by_user(bot: Bot, event_loop): + """ editMessageLiveLocation method test """ + from .types.dataset import MESSAGE_WITH_LOCATION, LOCATION + msg = types.Message(**MESSAGE_WITH_LOCATION) + location = types.Location(**LOCATION) + # editing user's message async with FakeTelegram(message_dict=True, loop=event_loop): result = await bot.edit_message_live_location(chat_id=msg.chat.id, message_id=msg.message_id, @@ -205,7 +213,7 @@ async def test_edit_message_live_location(bot: Bot, event_loop): @pytest.mark.asyncio -async def test_stop_message_live_location(bot: Bot, event_loop): +async def test_stop_message_live_location_by_bot(bot: Bot, event_loop): """ stopMessageLiveLocation method test """ from .types.dataset import MESSAGE_WITH_LOCATION msg = types.Message(**MESSAGE_WITH_LOCATION) @@ -215,6 +223,13 @@ async def test_stop_message_live_location(bot: Bot, event_loop): result = await bot.stop_message_live_location(chat_id=msg.chat.id, message_id=msg.message_id) assert result == msg + +@pytest.mark.asyncio +async def test_stop_message_live_location_by_user(bot: Bot, event_loop): + """ stopMessageLiveLocation method test """ + from .types.dataset import MESSAGE_WITH_LOCATION + msg = types.Message(**MESSAGE_WITH_LOCATION) + # stopping user's message async with FakeTelegram(message_dict=True, loop=event_loop): result = await bot.stop_message_live_location(chat_id=msg.chat.id, message_id=msg.message_id) @@ -509,7 +524,7 @@ async def test_answer_callback_query(bot: Bot, event_loop): @pytest.mark.asyncio -async def test_edit_message_text(bot: Bot, event_loop): +async def test_edit_message_text_by_bot(bot: Bot, event_loop): """ editMessageText method test """ from .types.dataset import EDITED_MESSAGE msg = types.Message(**EDITED_MESSAGE) @@ -519,6 +534,13 @@ async def test_edit_message_text(bot: Bot, event_loop): result = await bot.edit_message_text(text=msg.text, chat_id=msg.chat.id, message_id=msg.message_id) assert result == msg + +@pytest.mark.asyncio +async def test_edit_message_text_by_user(bot: Bot, event_loop): + """ editMessageText method test """ + from .types.dataset import EDITED_MESSAGE + msg = types.Message(**EDITED_MESSAGE) + # message by user async with FakeTelegram(message_dict=True, loop=event_loop): result = await bot.edit_message_text(text=msg.text, chat_id=msg.chat.id, message_id=msg.message_id) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 00000000..6ebaf472 --- /dev/null +++ b/tests/test_dispatcher.py @@ -0,0 +1,35 @@ +import pytest + +from aiogram import Dispatcher, Bot + +pytestmark = pytest.mark.asyncio + + +@pytest.yield_fixture() +async def bot(event_loop): + """ Bot fixture """ + _bot = Bot(token='123456789:AABBCCDDEEFFaabbccddeeff-1234567890', + loop=event_loop) + yield _bot + await _bot.close() + + +class TestDispatcherInit: + async def test_successful_init(self, bot): + """ + Success __init__ case + + :param bot: bot instance + :type bot: Bot + """ + dp = Dispatcher(bot=bot) + assert isinstance(dp, Dispatcher) + + @pytest.mark.parametrize("bot_instance", [None, Bot, 123, 'abc']) + async def test_wrong_bot_instance(self, bot_instance): + """ + User provides wrong data to 'bot' argument. + :return: TypeError with reason + """ + with pytest.raises(TypeError): + _ = Dispatcher(bot=bot_instance) diff --git a/tests/test_message.py b/tests/test_message.py new file mode 100644 index 00000000..996529f3 --- /dev/null +++ b/tests/test_message.py @@ -0,0 +1,47 @@ +from asyncio import BaseEventLoop + +import pytest + +from aiogram import Bot, types +from . import FakeTelegram, TOKEN + +pytestmark = pytest.mark.asyncio + + +@pytest.yield_fixture() +async def bot(event_loop): + """ Bot fixture """ + _bot = Bot(TOKEN, loop=event_loop, parse_mode=types.ParseMode.HTML) + yield _bot + await _bot.close() + + +@pytest.yield_fixture() +async def message(bot, event_loop): + """ + Message fixture + :param bot: Telegram bot fixture + :type bot: Bot + :param event_loop: asyncio event loop + :type event_loop: BaseEventLoop + """ + from .types.dataset import MESSAGE + msg = types.Message(**MESSAGE) + + async with FakeTelegram(message_dict=MESSAGE, loop=event_loop): + _message = await bot.send_message(chat_id=msg.chat.id, text=msg.text) + + yield _message + + +class TestMiscCases: + async def test_calling_bot_not_from_context(self, message): + """ + Calling any helper method without bot instance in context. + + :param message: message fixture + :type message: types.Message + :return: RuntimeError with reason and help + """ + with pytest.raises(RuntimeError): + await message.edit_text('test_calling_bot_not_from_context')