Merge pull request #3 from aiogram/dev-2.x

Update repository
This commit is contained in:
Arslan Sakhapov 2019-07-14 02:59:38 +05:00 committed by GitHub
commit 5410efca24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2648 additions and 410 deletions

2
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,2 @@
github: [JRootJunior]
open_collective: aiogram

19
.readthedocs.yml Normal file
View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -38,5 +38,5 @@ __all__ = [
'utils'
]
__version__ = '2.0.2.dev1'
__api_version__ = '4.1'
__version__ = '2.2.1.dev1'
__api_version__ = '4.3'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]]:
"""

View file

@ -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:
"""

View file

@ -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

View file

@ -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

View file

@ -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'
]

View file

@ -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 <https://core.telegram.org/bots#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

View file

@ -6,7 +6,7 @@ from ..handler import Handler
class FiltersFactory:
"""
Default filters factory
Filters factory
"""
def __init__(self, dispatcher):

View file

@ -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]}

View file

@ -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

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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]

View file

@ -1,6 +1,7 @@
import typing
from . import base
from . import fields
import typing
from .passport_file import PassportFile

View file

@ -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)

View file

@ -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
)

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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):

16
aiogram/types/poll.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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:
"""

View file

@ -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")

View file

@ -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)"

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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
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

View file

@ -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])

View file

@ -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

View file

@ -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
-------

View file

@ -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(

View file

@ -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__':

View file

@ -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: <i>{language}</i>').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)

View file

@ -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)

View file

@ -25,3 +25,7 @@ msgstr ""
msgid "Your current language: <i>{language}</i>"
msgstr ""
msgid "Aiogram has {number} like!"
msgid_plural "Aiogram has {number} likes!"
msgstr[0] ""
msgstr[1] ""

View file

@ -27,3 +27,8 @@ msgstr "Привет, <b>{user}</b>!"
msgid "Your current language: <i>{language}</i>"
msgstr "Твой язык: <i>{language}</i>"
msgid "Aiogram has {number} like!"
msgid_plural "Aiogram has {number} likes!"
msgstr[0] "Aiogram имеет {number} лайк!"
msgstr[1] "Aiogram имеет {number} лайка!"
msgstr[2] "Aiogram имеет {number} лайков!"

View file

@ -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'

View file

@ -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)

View file

@ -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):

View file

@ -1,5 +0,0 @@
conda:
file: environment.yml
python:
version: 3
pip_install: true

View file

@ -1,3 +1,3 @@
aiohttp>=3.4.4
aiohttp>=3.5.4
Babel>=2.6.0
certifi>=2018.8.24
certifi>=2019.3.9

View file

@ -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

View file

@ -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)

35
tests/test_dispatcher.py Normal file
View file

@ -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)

47
tests/test_message.py Normal file
View file

@ -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')