Merge branch 'dev-2.x' into aiov2

This commit is contained in:
Alex Root Junior 2020-09-13 22:08:43 +03:00 committed by GitHub
commit 9fe12e3aea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1719 additions and 707 deletions

View file

@ -6,7 +6,7 @@
[![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram)
[![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram)
[![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram)
[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api)
[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api)
[![Documentation Status](https://img.shields.io/readthedocs/aiogram?style=flat-square)](http://docs.aiogram.dev/en/latest/?badge=latest)
[![Github issues](https://img.shields.io/github/issues/aiogram/aiogram.svg?style=flat-square)](https://github.com/aiogram/aiogram/issues)
[![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT)

View file

@ -21,7 +21,7 @@ AIOGramBot
:target: https://pypi.python.org/pypi/aiogram
:alt: Supported python versions
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram
:target: https://core.telegram.org/bots/api
:alt: Telegram Bot API

View file

@ -43,5 +43,5 @@ __all__ = [
'utils'
]
__version__ = '2.8'
__api_version__ = '4.8'
__version__ = '2.9.2'
__api_version__ = '4.9'

View file

@ -153,7 +153,7 @@ class Methods(Helper):
"""
Helper for Telegram API Methods listed on https://core.telegram.org/bots/api
List is updated to Bot API 4.8
List is updated to Bot API 4.9
"""
mode = HelperMode.lowerCamelCase

View file

@ -398,6 +398,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
files = {}
prepare_file(payload, files, 'document', document)
prepare_attachment(payload, files, 'thumb', thumb)
result = await self.request(api.Methods.SEND_DOCUMENT, payload, files)
return types.Message(**result)
@ -503,7 +504,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
:param height: Animation height
:type height: :obj:`typing.Union[base.Integer, None]`
:param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size.
A thumbnails width and height should not exceed 90.
A thumbnails width and height should not exceed 320.
:type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]`
:param caption: Animation caption (may also be used when resending animation by file_id), 0-1024 characters
:type caption: :obj:`typing.Union[base.String, None]`
@ -886,6 +887,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
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.
Source: https://core.telegram.org/bots/api#sendpoll
: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.
@ -1953,7 +1956,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
files = {}
prepare_file(payload, files, 'png_sticker', png_sticker)
prepare_file(payload, files, 'tgs_sticker', png_sticker)
prepare_file(payload, files, 'tgs_sticker', tgs_sticker)
result = await self.request(api.Methods.ADD_STICKER_TO_SET, payload, files)
return result

View file

@ -1,12 +1,19 @@
"""
This module has mongo storage for finite-state machine
based on `aiomongo <https://github.com/ZeoAlliance/aiomongo`_ driver
based on `motor <https://github.com/mongodb/motor>`_ driver
"""
from typing import Union, Dict, Optional, List, Tuple, AnyStr
import aiomongo
from aiomongo import AioMongoClient, Database
import pymongo
try:
import motor
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
except ModuleNotFoundError as e:
import warnings
warnings.warn("Install motor with `pip install motor`")
raise e
from ...dispatcher.storage import BaseStorage
@ -35,22 +42,34 @@ class MongoStorage(BaseStorage):
"""
def __init__(self, host='localhost', port=27017, db_name='aiogram_fsm',
def __init__(self, host='localhost', port=27017, db_name='aiogram_fsm', uri=None,
username=None, password=None, index=True, **kwargs):
self._host = host
self._port = port
self._db_name: str = db_name
self._uri = uri
self._username = username
self._password = password
self._kwargs = kwargs
self._mongo: Union[AioMongoClient, None] = None
self._db: Union[Database, None] = None
self._mongo: Optional[AsyncIOMotorClient] = None
self._db: Optional[AsyncIOMotorDatabase] = None
self._index = index
async def get_client(self) -> AioMongoClient:
if isinstance(self._mongo, AioMongoClient):
async def get_client(self) -> AsyncIOMotorClient:
if isinstance(self._mongo, AsyncIOMotorClient):
return self._mongo
if self._uri:
try:
self._mongo = AsyncIOMotorClient(self._uri)
except pymongo.errors.ConfigurationError as e:
if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]:
import logging
logger = logging.getLogger("aiogram")
logger.warning("Run `pip install dnspython==1.16.0` in order to fix ConfigurationError. More information: https://github.com/mongodb/mongo-python-driver/pull/423#issuecomment-528998245")
raise e
return self._mongo
uri = 'mongodb://'
@ -63,16 +82,16 @@ class MongoStorage(BaseStorage):
uri += f'{self._host}:{self._port}' if self._host else f'localhost:{self._port}'
# define and return client
self._mongo = await aiomongo.create_client(uri)
self._mongo = AsyncIOMotorClient(uri)
return self._mongo
async def get_db(self) -> Database:
async def get_db(self) -> AsyncIOMotorDatabase:
"""
Get Mongo db
This property is awaitable.
"""
if isinstance(self._db, Database):
if isinstance(self._db, AsyncIOMotorDatabase):
return self._db
mongo = await self.get_client()
@ -93,8 +112,6 @@ class MongoStorage(BaseStorage):
self._mongo.close()
async def wait_closed(self):
if self._mongo:
return await self._mongo.wait_closed()
return True
async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None,

View file

@ -208,7 +208,7 @@ class RedisStorage2(BaseStorage):
.. code-block:: python3
storage = RedisStorage('localhost', 6379, db=5, pool_size=10, prefix='my_fsm_key')
storage = RedisStorage2('localhost', 6379, db=5, pool_size=10, prefix='my_fsm_key')
dp = Dispatcher(bot, storage=storage)
And need to close Redis connection when shutdown

View file

@ -59,13 +59,13 @@ class I18nMiddleware(BaseMiddleware):
with open(mo_path, 'rb') as fp:
translations[name] = gettext.GNUTranslations(fp)
elif os.path.exists(mo_path[:-2] + 'po'):
raise RuntimeError(f"Found locale '{name} but this language is not compiled!")
raise RuntimeError(f"Found locale '{name}' but this language is not compiled!")
return translations
def reload(self):
"""
Hot reload locles
Hot reload locales
"""
self.locales = self.find_locales()

View file

@ -10,8 +10,8 @@ from aiohttp.helpers import sentinel
from aiogram.utils.deprecated import renamed_argument
from .filters import Command, ContentTypeFilter, ExceptionsFilter, FiltersFactory, HashTag, Regexp, \
RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter
from .filters.builtin import IsSenderContact
RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter, \
IsSenderContact, ChatTypeFilter, AbstractFilter
from .handler import Handler
from .middlewares import MiddlewareManager
from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \
@ -160,6 +160,19 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
self.channel_post_handlers,
self.edited_channel_post_handlers,
])
filters_factory.bind(ForwardedMessageFilter, event_handlers=[
self.message_handlers,
self.edited_channel_post_handlers,
self.channel_post_handlers,
self.edited_channel_post_handlers
])
filters_factory.bind(ChatTypeFilter, event_handlers=[
self.message_handlers,
self.edited_message_handlers,
self.channel_post_handlers,
self.edited_channel_post_handlers,
self.callback_query_handlers,
])
def __del__(self):
self.stop_polling()
@ -203,39 +216,50 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
try:
if update.message:
types.Message.set_current(update.message)
types.User.set_current(update.message.from_user)
types.Chat.set_current(update.message.chat)
return await self.message_handlers.notify(update.message)
if update.edited_message:
types.Message.set_current(update.edited_message)
types.User.set_current(update.edited_message.from_user)
types.Chat.set_current(update.edited_message.chat)
return await self.edited_message_handlers.notify(update.edited_message)
if update.channel_post:
types.Message.set_current(update.channel_post)
types.Chat.set_current(update.channel_post.chat)
return await self.channel_post_handlers.notify(update.channel_post)
if update.edited_channel_post:
types.Message.set_current(update.edited_channel_post)
types.Chat.set_current(update.edited_channel_post.chat)
return await self.edited_channel_post_handlers.notify(update.edited_channel_post)
if update.inline_query:
types.InlineQuery.set_current(update.inline_query)
types.User.set_current(update.inline_query.from_user)
return await self.inline_query_handlers.notify(update.inline_query)
if update.chosen_inline_result:
types.ChosenInlineResult.set_current(update.chosen_inline_result)
types.User.set_current(update.chosen_inline_result.from_user)
return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result)
if update.callback_query:
types.CallbackQuery.set_current(update.callback_query)
if update.callback_query.message:
types.Chat.set_current(update.callback_query.message.chat)
types.User.set_current(update.callback_query.from_user)
return await self.callback_query_handlers.notify(update.callback_query)
if update.shipping_query:
types.ShippingQuery.set_current(update.shipping_query)
types.User.set_current(update.shipping_query.from_user)
return await self.shipping_query_handlers.notify(update.shipping_query)
if update.pre_checkout_query:
types.PreCheckoutQuery.set_current(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:
types.Poll.set_current(update.poll)
return await self.poll_handlers.notify(update.poll)
if update.poll_answer:
types.PollAnswer.set_current(update.poll_answer)
types.User.set_current(update.poll_answer.user)
return await self.poll_answer_handlers.notify(update.poll_answer)
except Exception as e:
@ -421,7 +445,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
.. code-block:: python3
@dp.message_handler(rexexp='^[a-z]+-[0-9]+')
@dp.message_handler(regexp='^[a-z]+-[0-9]+')
async def msg_handler(message: types.Message):
Filter messages by command regular expression:
@ -1215,3 +1239,35 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
return wrapped
return decorator
def bind_filter(self, callback: typing.Union[typing.Callable, AbstractFilter],
validator: typing.Optional[typing.Callable] = None,
event_handlers: typing.Optional[typing.List[Handler]] = None,
exclude_event_handlers: typing.Optional[typing.Iterable[Handler]] = None):
"""
Register filter
:param callback: callable or subclass of :obj:`AbstractFilter`
:param validator: custom validator.
:param event_handlers: list of instances of :obj:`Handler`
:param exclude_event_handlers: list of excluded event handlers (:obj:`Handler`)
"""
self.filters_factory.bind(callback=callback, validator=validator, event_handlers=event_handlers,
exclude_event_handlers=exclude_event_handlers)
def unbind_filter(self, callback: typing.Union[typing.Callable, AbstractFilter]):
"""
Unregister filter
:param callback: callable of subclass of :obj:`AbstractFilter`
"""
self.filters_factory.unbind(callback=callback)
def setup_middleware(self, middleware):
"""
Setup middleware
:param middleware:
:return:
"""
self.middleware.setup(middleware)

View file

@ -1,6 +1,7 @@
from .builtin import Command, CommandHelp, CommandPrivacy, CommandSettings, CommandStart, ContentTypeFilter, \
ExceptionsFilter, HashTag, Regexp, RegexpCommandsFilter, StateFilter, \
Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact
Text, IDFilter, AdminFilter, IsReplyFilter, IsSenderContact, ForwardedMessageFilter, \
ChatTypeFilter
from .factory import FiltersFactory
from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \
check_filters, get_filter_spec, get_filters_spec
@ -32,4 +33,6 @@ __all__ = [
'get_filters_spec',
'execute_filter',
'check_filters',
'ForwardedMessageFilter',
'ChatTypeFilter',
]

View file

@ -1,6 +1,7 @@
import inspect
import re
import typing
import warnings
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Optional, Union
@ -9,8 +10,7 @@ from babel.support import LazyProxy
from aiogram import types
from aiogram.dispatcher.filters.filters import BoundFilter, Filter
from aiogram.types import CallbackQuery, Message, InlineQuery, Poll, ChatType
from aiogram.types import CallbackQuery, ChatType, InlineQuery, Message, Poll
ChatIDArgumentType = typing.Union[typing.Iterable[typing.Union[int, str]], str, int]
@ -37,7 +37,8 @@ class Command(Filter):
def __init__(self, commands: Union[Iterable, str],
prefixes: Union[Iterable, str] = '/',
ignore_case: bool = True,
ignore_mention: bool = False):
ignore_mention: bool = False,
ignore_caption: bool = True):
"""
Filter can be initialized from filters factory or by simply creating instance of this class.
@ -55,6 +56,15 @@ class Command(Filter):
: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)
:param ignore_caption: Ignore caption from message (in message types like photo, video, audio, etc)
By default is True. If you want check commands in captions, you also should set required content_types.
Examples:
.. code-block:: python
@dp.message_handler(commands=['myCommand'], commands_ignore_caption=False, content_types=ContentType.ANY)
@dp.message_handler(Command(['myCommand'], ignore_caption=False), content_types=[ContentType.TEXT, ContentType.DOCUMENT])
"""
if isinstance(commands, str):
commands = (commands,)
@ -63,6 +73,7 @@ class Command(Filter):
self.prefixes = prefixes
self.ignore_case = ignore_case
self.ignore_mention = ignore_mention
self.ignore_caption = ignore_caption
@classmethod
def validate(cls, full_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -73,7 +84,8 @@ class Command(Filter):
- ``command``
- ``commands_prefix`` (will be passed as ``prefixes``)
- ``commands_ignore_mention`` (will be passed as ``ignore_mention``
- ``commands_ignore_mention`` (will be passed as ``ignore_mention``)
- ``commands_ignore_caption`` (will be passed as ``ignore_caption``)
:param full_config:
:return: config or empty dict
@ -85,17 +97,20 @@ class Command(Filter):
config['prefixes'] = full_config.pop('commands_prefix')
if config and 'commands_ignore_mention' in full_config:
config['ignore_mention'] = full_config.pop('commands_ignore_mention')
if config and 'commands_ignore_caption' in full_config:
config['ignore_caption'] = full_config.pop('commands_ignore_caption')
return config
async def check(self, message: types.Message):
return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention)
return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention, self.ignore_caption)
@staticmethod
async def check_command(message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False):
if not message.text: # Prevent to use with non-text content types
@classmethod
async def check_command(cls, message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False, ignore_caption=True):
text = message.text or (message.caption if not ignore_caption else None)
if not text:
return False
full_command = message.text.split()[0]
full_command = text.split()[0]
prefix, (command, _, mention) = full_command[0], full_command[1:].partition('@')
if not ignore_mention and mention and (await message.bot.me).username.lower() != mention.lower():
@ -105,7 +120,7 @@ class Command(Filter):
if (command.lower() if ignore_case else command) not in commands:
return False
return {'command': Command.CommandObj(command=command, prefix=prefix, mention=mention)}
return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention)}
@dataclass
class CommandObj:
@ -591,7 +606,9 @@ class IDFilter(Filter):
async def check(self, obj: Union[Message, CallbackQuery, InlineQuery]):
if isinstance(obj, Message):
user_id = obj.from_user.id
user_id = None
if obj.from_user is not None:
user_id = obj.from_user.id
chat_id = obj.chat.id
elif isinstance(obj, CallbackQuery):
user_id = obj.from_user.id
@ -681,3 +698,34 @@ class IsReplyFilter(BoundFilter):
return {'reply': msg.reply_to_message}
elif not msg.reply_to_message and not self.is_reply:
return True
class ForwardedMessageFilter(BoundFilter):
key = 'is_forwarded'
def __init__(self, is_forwarded: bool):
self.is_forwarded = is_forwarded
async def check(self, message: Message):
return bool(getattr(message, "forward_date")) is self.is_forwarded
class ChatTypeFilter(BoundFilter):
key = 'chat_type'
def __init__(self, chat_type: typing.Container[ChatType]):
if isinstance(chat_type, str):
chat_type = {chat_type}
self.chat_type: typing.Set[str] = set(chat_type)
async def check(self, obj: Union[Message, CallbackQuery]):
if isinstance(obj, Message):
obj = obj.chat
elif isinstance(obj, CallbackQuery):
obj = obj.message.chat
else:
warnings.warn("ChatTypeFilter doesn't support %s as input", type(obj))
return False
return obj.type in self.chat_type

View file

@ -30,7 +30,7 @@ class FiltersFactory:
def unbind(self, callback: typing.Union[typing.Callable, AbstractFilter]):
"""
Unregister callback
Unregister filter
:param callback: callable of subclass of :obj:`AbstractFilter`
"""

View file

@ -33,7 +33,7 @@ def _check_spec(spec: inspect.FullArgSpec, kwargs: dict):
if spec.varkw:
return kwargs
return {k: v for k, v in kwargs.items() if k in spec.args}
return {k: v for k, v in kwargs.items() if k in set(spec.args + spec.kwonlyargs)}
class Handler:

View file

@ -15,6 +15,9 @@ class Animation(base.TelegramObject, mixins.Downloadable):
file_id: base.String = fields.Field()
file_unique_id: base.String = fields.Field()
width: base.Integer = fields.Field()
height: base.Integer = fields.Field()
duration: base.Integer = fields.Field()
thumb: PhotoSize = fields.Field(base=PhotoSize)
file_name: base.String = fields.Field()
mime_type: base.String = fields.Field()

View file

@ -54,8 +54,11 @@ class CallbackQuery(base.TelegramObject):
:type cache_time: :obj:`typing.Union[base.Integer, None]`
:return: On success, True is returned.
:rtype: :obj:`base.Boolean`"""
await self.bot.answer_callback_query(callback_query_id=self.id, text=text,
show_alert=show_alert, url=url, cache_time=cache_time)
return await self.bot.answer_callback_query(callback_query_id=self.id,
text=text,
show_alert=show_alert,
url=url,
cache_time=cache_time)
def __hash__(self):
return hash(self.id)

View file

@ -10,6 +10,7 @@ from .chat_member import ChatMember
from .chat_permissions import ChatPermissions
from .chat_photo import ChatPhoto
from .input_file import InputFile
from ..utils.deprecated import deprecated
class Chat(base.TelegramObject):
@ -512,6 +513,7 @@ class ChatType(helper.Helper):
return obj.type in chat_types
@classmethod
@deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0")
def is_private(cls, obj) -> bool:
"""
Check chat is private
@ -522,6 +524,7 @@ class ChatType(helper.Helper):
return cls._check(obj, [cls.PRIVATE])
@classmethod
@deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0")
def is_group(cls, obj) -> bool:
"""
Check chat is group
@ -532,6 +535,7 @@ class ChatType(helper.Helper):
return cls._check(obj, [cls.GROUP])
@classmethod
@deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0")
def is_super_group(cls, obj) -> bool:
"""
Check chat is super-group
@ -542,6 +546,7 @@ class ChatType(helper.Helper):
return cls._check(obj, [cls.SUPER_GROUP])
@classmethod
@deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0")
def is_group_or_super_group(cls, obj) -> bool:
"""
Check chat is group or super-group
@ -552,6 +557,7 @@ class ChatType(helper.Helper):
return cls._check(obj, [cls.GROUP, cls.SUPER_GROUP])
@classmethod
@deprecated("This filter was moved to ChatTypeFilter, and will be removed in aiogram v3.0")
def is_channel(cls, obj) -> bool:
"""
Check chat is channel

View file

@ -16,3 +16,4 @@ class Dice(base.TelegramObject):
class DiceEmoji:
DICE = '🎲'
DART = '🎯'
BASKETBALL = '🏀'

View file

@ -1,5 +1,6 @@
import abc
import datetime
import weakref
__all__ = ('BaseField', 'Field', 'ListField', 'DateTimeField', 'TextField', 'ListOfLists')
@ -109,7 +110,9 @@ class Field(BaseField):
and self.base_object is not None \
and not hasattr(value, 'base_object') \
and not hasattr(value, 'to_python'):
return self.base_object(conf={'parent': parent}, **value)
if not isinstance(parent, weakref.ReferenceType):
parent = weakref.ref(parent)
return self.base_object(conf={'parent':parent}, **value)
return value

View file

@ -118,6 +118,7 @@ class InlineQueryResultGif(InlineQueryResult):
gif_height: base.Integer = fields.Field()
gif_duration: base.Integer = fields.Field()
thumb_url: base.String = fields.Field()
thumb_mime_type: base.String = fields.Field()
title: base.String = fields.Field()
caption: base.String = fields.Field()
input_message_content: InputMessageContent = fields.Field(base=InputMessageContent)
@ -157,6 +158,7 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult):
mpeg4_height: base.Integer = fields.Field()
mpeg4_duration: base.Integer = fields.Field()
thumb_url: base.String = fields.Field()
thumb_mime_type: base.String = fields.Field()
title: base.String = fields.Field()
caption: base.String = fields.Field()
input_message_content: InputMessageContent = fields.Field(base=InputMessageContent)

View file

@ -132,13 +132,11 @@ class InputMediaDocument(InputMedia):
class InputMediaAudio(InputMedia):
"""
Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent.
Represents an audio file to be treated as music to be sent.
https://core.telegram.org/bots/api#inputmediaanimation
https://core.telegram.org/bots/api#inputmediaaudio
"""
width: base.Integer = fields.Field()
height: base.Integer = fields.Field()
duration: base.Integer = fields.Field()
performer: base.String = fields.Field()
title: base.String = fields.Field()
@ -146,13 +144,12 @@ class InputMediaAudio(InputMedia):
def __init__(self, media: base.InputFile,
thumb: typing.Union[base.InputFile, base.String] = None,
caption: base.String = None,
width: base.Integer = None, height: base.Integer = None,
duration: base.Integer = None,
performer: base.String = None,
title: base.String = None,
parse_mode: base.String = None, **kwargs):
super(InputMediaAudio, self).__init__(type='audio', media=media, thumb=thumb, caption=caption,
width=width, height=height, duration=duration,
super(InputMediaAudio, self).__init__(type='audio', media=media, thumb=thumb,
caption=caption, duration=duration,
performer=performer, title=title,
parse_mode=parse_mode, conf=kwargs)

File diff suppressed because it is too large Load diff

View file

@ -111,11 +111,13 @@ class KeyboardButton(base.TelegramObject):
def __init__(self, text: base.String,
request_contact: base.Boolean = None,
request_location: base.Boolean = None,
request_poll: KeyboardButtonPollType = None):
request_poll: KeyboardButtonPollType = None,
**kwargs):
super(KeyboardButton, self).__init__(text=text,
request_contact=request_contact,
request_location=request_location,
request_poll=request_poll)
request_poll=request_poll,
**kwargs)
class ReplyKeyboardRemove(base.TelegramObject):

View file

@ -9,7 +9,7 @@ from .message import Message
from .poll import Poll, PollAnswer
from .pre_checkout_query import PreCheckoutQuery
from .shipping_query import ShippingQuery
from ..utils import helper
from ..utils import helper, deprecated
class Update(base.TelegramObject):
@ -55,9 +55,15 @@ class AllowedUpdates(helper.Helper):
CHANNEL_POST = helper.ListItem() # channel_post
EDITED_CHANNEL_POST = helper.ListItem() # edited_channel_post
INLINE_QUERY = helper.ListItem() # inline_query
CHOSEN_INLINE_QUERY = helper.ListItem() # chosen_inline_result
CHOSEN_INLINE_RESULT = helper.ListItem() # chosen_inline_result
CALLBACK_QUERY = helper.ListItem() # callback_query
SHIPPING_QUERY = helper.ListItem() # shipping_query
PRE_CHECKOUT_QUERY = helper.ListItem() # pre_checkout_query
POLL = helper.ListItem() # poll
POLL_ANSWER = helper.ListItem() # poll_answer
CHOSEN_INLINE_QUERY = deprecated.DeprecatedReadOnlyClassVar(
"`CHOSEN_INLINE_QUERY` is a deprecated value for allowed update. "
"Use `CHOSEN_INLINE_RESULT`",
new_value_getter=lambda cls: cls.CHOSEN_INLINE_RESULT,
)

View file

@ -2,7 +2,7 @@ import asyncio
import inspect
import warnings
import functools
from typing import Callable
from typing import Callable, Generic, TypeVar, Type, Optional
def deprecated(reason, stacklevel=2) -> Callable:
@ -129,3 +129,36 @@ def renamed_argument(old_name: str, new_name: str, until_version: str, stackleve
return wrapped
return decorator
_VT = TypeVar("_VT")
_OwnerCls = TypeVar("_OwnerCls")
class DeprecatedReadOnlyClassVar(Generic[_OwnerCls, _VT]):
"""
DeprecatedReadOnlyClassVar[Owner, ValueType]
:param warning_message: Warning message when getter gets called
:param new_value_getter: Any callable with (owner_class: Type[Owner]) -> ValueType
signature that will be executed
Usage example:
>>> class MyClass:
... some_attribute: DeprecatedReadOnlyClassVar[MyClass, int] = \
... DeprecatedReadOnlyClassVar(
... "Warning message.", lambda owner: 15)
...
>>> MyClass.some_attribute # does warning.warn with `Warning message` and returns 15 in the current case
"""
__slots__ = "_new_value_getter", "_warning_message"
def __init__(self, warning_message: str, new_value_getter: Callable[[_OwnerCls], _VT]):
self._warning_message = warning_message
self._new_value_getter = new_value_getter
def __get__(self, instance: Optional[_OwnerCls], owner: Type[_OwnerCls]):
warn_deprecated(self._warning_message, stacklevel=3)
return self._new_value_getter(owner)

View file

@ -7,6 +7,7 @@
- MessageNotModified
- MessageToForwardNotFound
- MessageToDeleteNotFound
- MessageToPinNotFound
- MessageIdentifierNotSpecified
- MessageTextIsEmpty
- MessageCantBeEdited
@ -182,6 +183,13 @@ class MessageToDeleteNotFound(MessageError):
match = 'message to delete not found'
class MessageToPinNotFound(MessageError):
"""
Will be raised when you try to pin deleted or unknown message.
"""
match = 'message to pin not found'
class MessageToReplyNotFound(MessageError):
"""
Will be raised when you try to reply to very old or deleted or unknown message.

View file

@ -18,7 +18,7 @@ HTML_QUOTES_MAP = {"<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;"}
_HQS = HTML_QUOTES_MAP.keys() # HQS for HTML QUOTES SYMBOLS
def quote_html(*content, sep=" "):
def quote_html(*content, sep=" ") -> str:
"""
Quote HTML symbols
@ -33,7 +33,7 @@ def quote_html(*content, sep=" "):
return html_decoration.quote(_join(*content, sep=sep))
def escape_md(*content, sep=" "):
def escape_md(*content, sep=" ") -> str:
"""
Escape markdown text
@ -61,7 +61,7 @@ def text(*content, sep=" "):
return _join(*content, sep=sep)
def bold(*content, sep=" "):
def bold(*content, sep=" ") -> str:
"""
Make bold text (Markdown)
@ -69,12 +69,12 @@ def bold(*content, sep=" "):
:param sep:
:return:
"""
return markdown_decoration.bold.format(
return markdown_decoration.bold(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hbold(*content, sep=" "):
def hbold(*content, sep=" ") -> str:
"""
Make bold text (HTML)
@ -82,12 +82,12 @@ def hbold(*content, sep=" "):
:param sep:
:return:
"""
return html_decoration.bold.format(
return html_decoration.bold(
value=html_decoration.quote(_join(*content, sep=sep))
)
def italic(*content, sep=" "):
def italic(*content, sep=" ") -> str:
"""
Make italic text (Markdown)
@ -95,12 +95,12 @@ def italic(*content, sep=" "):
:param sep:
:return:
"""
return markdown_decoration.italic.format(
return markdown_decoration.italic(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hitalic(*content, sep=" "):
def hitalic(*content, sep=" ") -> str:
"""
Make italic text (HTML)
@ -108,12 +108,12 @@ def hitalic(*content, sep=" "):
:param sep:
:return:
"""
return html_decoration.italic.format(
return html_decoration.italic(
value=html_decoration.quote(_join(*content, sep=sep))
)
def code(*content, sep=" "):
def code(*content, sep=" ") -> str:
"""
Make mono-width text (Markdown)
@ -121,12 +121,12 @@ def code(*content, sep=" "):
:param sep:
:return:
"""
return markdown_decoration.code.format(
return markdown_decoration.code(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hcode(*content, sep=" "):
def hcode(*content, sep=" ") -> str:
"""
Make mono-width text (HTML)
@ -134,12 +134,12 @@ def hcode(*content, sep=" "):
:param sep:
:return:
"""
return html_decoration.code.format(
return html_decoration.code(
value=html_decoration.quote(_join(*content, sep=sep))
)
def pre(*content, sep="\n"):
def pre(*content, sep="\n") -> str:
"""
Make mono-width text block (Markdown)
@ -147,12 +147,12 @@ def pre(*content, sep="\n"):
:param sep:
:return:
"""
return markdown_decoration.pre.format(
return markdown_decoration.pre(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hpre(*content, sep="\n"):
def hpre(*content, sep="\n") -> str:
"""
Make mono-width text block (HTML)
@ -160,12 +160,12 @@ def hpre(*content, sep="\n"):
:param sep:
:return:
"""
return html_decoration.pre.format(
return html_decoration.pre(
value=html_decoration.quote(_join(*content, sep=sep))
)
def underline(*content, sep=" "):
def underline(*content, sep=" ") -> str:
"""
Make underlined text (Markdown)
@ -173,12 +173,12 @@ def underline(*content, sep=" "):
:param sep:
:return:
"""
return markdown_decoration.underline.format(
return markdown_decoration.underline(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hunderline(*content, sep=" "):
def hunderline(*content, sep=" ") -> str:
"""
Make underlined text (HTML)
@ -186,12 +186,12 @@ def hunderline(*content, sep=" "):
:param sep:
:return:
"""
return html_decoration.underline.format(
return html_decoration.underline(
value=html_decoration.quote(_join(*content, sep=sep))
)
def strikethrough(*content, sep=" "):
def strikethrough(*content, sep=" ") -> str:
"""
Make strikethrough text (Markdown)
@ -199,12 +199,12 @@ def strikethrough(*content, sep=" "):
:param sep:
:return:
"""
return markdown_decoration.strikethrough.format(
return markdown_decoration.strikethrough(
value=markdown_decoration.quote(_join(*content, sep=sep))
)
def hstrikethrough(*content, sep=" "):
def hstrikethrough(*content, sep=" ") -> str:
"""
Make strikethrough text (HTML)
@ -212,7 +212,7 @@ def hstrikethrough(*content, sep=" "):
:param sep:
:return:
"""
return html_decoration.strikethrough.format(
return html_decoration.strikethrough(
value=html_decoration.quote(_join(*content, sep=sep))
)
@ -225,7 +225,7 @@ def link(title: str, url: str) -> str:
:param url:
:return:
"""
return markdown_decoration.link.format(value=markdown_decoration.quote(title), link=url)
return markdown_decoration.link(value=markdown_decoration.quote(title), link=url)
def hlink(title: str, url: str) -> str:
@ -236,7 +236,7 @@ def hlink(title: str, url: str) -> str:
:param url:
:return:
"""
return html_decoration.link.format(value=html_decoration.quote(title), link=url)
return html_decoration.link(value=html_decoration.quote(title), link=url)
def hide_link(url: str) -> str:

View file

@ -1,33 +1,23 @@
from __future__ import annotations
import html
import re
import struct
from dataclasses import dataclass
from typing import TYPE_CHECKING, AnyStr, Callable, Generator, Iterable, List, Optional
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Generator, List, Optional, Pattern, cast
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from aiogram.types import MessageEntity
__all__ = (
"TextDecoration",
"HtmlDecoration",
"MarkdownDecoration",
"html_decoration",
"markdown_decoration",
"add_surrogate",
"remove_surrogate",
)
@dataclass
class TextDecoration:
link: str
bold: str
italic: str
code: str
pre: str
underline: str
strikethrough: str
quote: Callable[[AnyStr], AnyStr]
class TextDecoration(ABC):
def apply_entity(self, entity: MessageEntity, text: str) -> str:
"""
Apply single entity to text
@ -36,24 +26,28 @@ class TextDecoration:
:param text:
:return:
"""
if entity.type in (
"bold",
"italic",
"code",
"pre",
"underline",
"strikethrough",
):
return getattr(self, entity.type).format(value=text)
elif entity.type == "text_mention":
return self.link.format(value=text, link=f"tg://user?id={entity.user.id}")
elif entity.type == "text_link":
return self.link.format(value=text, link=entity.url)
elif entity.type == "url":
if entity.type in {"bot_command", "url", "mention", "phone_number"}:
# This entities should not be changed
return text
if entity.type in {"bold", "italic", "code", "underline", "strikethrough"}:
return cast(str, getattr(self, entity.type)(value=text))
if entity.type == "pre":
return (
self.pre_language(value=text, language=entity.language)
if entity.language
else self.pre(value=text)
)
if entity.type == "text_mention":
from aiogram.types import User
user = cast(User, entity.user)
return self.link(value=text, link=f"tg://user?id={user.id}")
if entity.type == "text_link":
return self.link(value=text, link=cast(str, entity.url))
return self.quote(text)
def unparse(self, text, entities: Optional[List[MessageEntity]] = None) -> str:
def unparse(self, text: str, entities: Optional[List[MessageEntity]] = None) -> str:
"""
Unparse message entities
@ -61,34 +55,34 @@ class TextDecoration:
:param entities: Array of MessageEntities
:return:
"""
text = add_surrogate(text)
result = "".join(
self._unparse_entities(
text, sorted(entities, key=lambda item: item.offset) if entities else []
self._add_surrogates(text), sorted(entities, key=lambda item: item.offset) if entities else []
)
)
return remove_surrogate(result)
return result
def _unparse_entities(
self,
text: str,
entities: Iterable[MessageEntity],
text: bytes,
entities: List[MessageEntity],
offset: Optional[int] = None,
length: Optional[int] = None,
) -> Generator[str, None, None]:
offset = offset or 0
if offset is None:
offset = 0
length = length or len(text)
for index, entity in enumerate(entities):
if entity.offset < offset:
if entity.offset * 2 < offset:
continue
if entity.offset > offset:
yield self.quote(text[offset : entity.offset])
start = entity.offset
offset = entity.offset + entity.length
if entity.offset * 2 > offset:
yield self.quote(self._remove_surrogates(text[offset : entity.offset * 2]))
start = entity.offset * 2
offset = entity.offset * 2 + entity.length * 2
sub_entities = list(
filter(lambda e: e.offset < offset, entities[index + 1 :])
filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :])
)
yield self.apply_entity(
entity,
@ -100,44 +94,112 @@ class TextDecoration:
)
if offset < length:
yield self.quote(text[offset:length])
yield self.quote(self._remove_surrogates(text[offset:length]))
@staticmethod
def _add_surrogates(text: str):
return text.encode('utf-16-le')
@staticmethod
def _remove_surrogates(text: bytes):
return text.decode('utf-16-le')
@abstractmethod
def link(self, value: str, link: str) -> str: # pragma: no cover
pass
@abstractmethod
def bold(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def italic(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def code(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def pre(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def pre_language(self, value: str, language: str) -> str: # pragma: no cover
pass
@abstractmethod
def underline(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def strikethrough(self, value: str) -> str: # pragma: no cover
pass
@abstractmethod
def quote(self, value: str) -> str: # pragma: no cover
pass
html_decoration = TextDecoration(
link='<a href="{link}">{value}</a>',
bold="<b>{value}</b>",
italic="<i>{value}</i>",
code="<code>{value}</code>",
pre="<pre>{value}</pre>",
underline="<u>{value}</u>",
strikethrough="<s>{value}</s>",
quote=html.escape,
)
class HtmlDecoration(TextDecoration):
def link(self, value: str, link: str) -> str:
return f'<a href="{link}">{value}</a>'
MARKDOWN_QUOTE_PATTERN = re.compile(r"([_*\[\]()~`>#+\-=|{}.!])")
def bold(self, value: str) -> str:
return f"<b>{value}</b>"
markdown_decoration = TextDecoration(
link="[{value}]({link})",
bold="*{value}*",
italic="_{value}_\r",
code="`{value}`",
pre="```{value}```",
underline="__{value}__",
strikethrough="~{value}~",
quote=lambda text: re.sub(
pattern=MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=text
),
)
def italic(self, value: str) -> str:
return f"<i>{value}</i>"
def code(self, value: str) -> str:
return f"<code>{value}</code>"
def pre(self, value: str) -> str:
return f"<pre>{value}</pre>"
def pre_language(self, value: str, language: str) -> str:
return f'<pre><code class="language-{language}">{value}</code></pre>'
def underline(self, value: str) -> str:
return f"<u>{value}</u>"
def strikethrough(self, value: str) -> str:
return f"<s>{value}</s>"
def quote(self, value: str) -> str:
return html.escape(value, quote=False)
def add_surrogate(text: str) -> str:
return "".join(
"".join(chr(d) for d in struct.unpack("<HH", s.encode("utf-16-le")))
if (0x10000 <= ord(s) <= 0x10FFFF)
else s
for s in text
)
class MarkdownDecoration(TextDecoration):
MARKDOWN_QUOTE_PATTERN: Pattern[str] = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
def link(self, value: str, link: str) -> str:
return f"[{value}]({link})"
def bold(self, value: str) -> str:
return f"*{value}*"
def italic(self, value: str) -> str:
return f"_{value}_\r"
def code(self, value: str) -> str:
return f"`{value}`"
def pre(self, value: str) -> str:
return f"```{value}```"
def pre_language(self, value: str, language: str) -> str:
return f"```{language}\n{value}\n```"
def underline(self, value: str) -> str:
return f"__{value}__"
def strikethrough(self, value: str) -> str:
return f"~{value}~"
def quote(self, value: str) -> str:
return re.sub(pattern=self.MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=value)
def remove_surrogate(text: str) -> str:
return text.encode("utf-16", "surrogatepass").decode("utf-16")
html_decoration = HtmlDecoration()
markdown_decoration = MarkdownDecoration()

View file

@ -141,6 +141,22 @@ IsReplyFilter
:show-inheritance:
ForwardedMessageFilter
-------------
.. autoclass:: aiogram.dispatcher.filters.filters.ForwardedMessageFilter
:members:
:show-inheritance:
ChatTypeFilter
-------------
.. autoclass:: aiogram.dispatcher.filters.filters.ChatTypeFilter
:members:
:show-inheritance:
Making own filters (Custom filters)
===================================

View file

@ -22,7 +22,7 @@ Welcome to aiogram's documentation!
:target: https://pypi.python.org/pypi/aiogram
:alt: Supported python versions
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.8-blue.svg?style=flat-square&logo=telegram
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-4.9-blue.svg?style=flat-square&logo=telegram
:target: https://core.telegram.org/bots/api
:alt: Telegram Bot API

View file

@ -28,13 +28,13 @@ If you want to handle all messages in the chat simply add handler without filter
.. literalinclude:: ../../examples/echo_bot.py
:language: python
:lines: 35-37
:lines: 44-49
Last step: run long polling.
.. literalinclude:: ../../examples/echo_bot.py
:language: python
:lines: 40-41
:lines: 52-53
Summary
-------
@ -42,4 +42,4 @@ Summary
.. literalinclude:: ../../examples/echo_bot.py
:language: python
:linenos:
:lines: -19,27-
:lines: -27,43-

View file

@ -112,7 +112,7 @@ async def query_post_vote(query: types.CallbackQuery, callback_data: dict):
@dp.errors_handler(exception=MessageNotModified)
async def message_not_modified_handler(update, error):
return True
return True # errors_handler must return True if error was handled correctly
if __name__ == '__main__':

View file

@ -61,7 +61,7 @@ async def callback_vote_action(query: types.CallbackQuery, callback_data: dict):
@dp.errors_handler(exception=MessageNotModified) # handle the cases when this exception raises
async def message_not_modified_handler(update, error):
return True
return True # errors_handler must return True if error was handled correctly
if __name__ == '__main__':

View file

@ -0,0 +1,42 @@
"""
This is an example with usage of ChatTypeFilter
It filters incoming object based on type of its chat type
"""
import logging
from aiogram import Bot, Dispatcher, executor, types
from aiogram.dispatcher.handler import SkipHandler
from aiogram.types import ChatType
API_TOKEN = 'BOT TOKEN HERE'
# Configure logging
logging.basicConfig(level=logging.INFO)
# Initialize bot and dispatcher
bot = Bot(token=API_TOKEN)
dp = Dispatcher(bot)
@dp.message_handler(chat_type=[ChatType.PRIVATE, ChatType.CHANNEL])
async def send_welcome(message: types.Message):
"""
This handler will be called when user sends `/start` or `/help` command
"""
await message.reply("Hi!\nI'm hearing your messages in private chats and channels")
# propagate message to the next handler
raise SkipHandler
@dp.message_handler(chat_type=ChatType.PRIVATE)
async def send_welcome(message: types.Message):
"""
This handler will be called when user sends `/start` or `/help` command
"""
await message.reply("Hi!\nI'm hearing your messages only in private chats")
if __name__ == '__main__':
executor.start_polling(dp, skip_updates=True)

View file

@ -1,8 +1,8 @@
"""
Internalize your bot
Internationalize your bot
Step 1: extract texts
# pybabel extract i18n_example.py -o locales/mybot.pot
# pybabel extract --input-dirs=. -o locales/mybot.pot
Some useful options:
- Extract texts with pluralization support
@ -16,9 +16,14 @@ Step 1: extract texts
- 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
Step 2: create *.po files. E.g. create en, ru, uk locales.
# pybabel init -i locales/mybot.pot -d locales -D mybot -l en
# pybabel init -i locales/mybot.pot -d locales -D mybot -l ru
# pybabel init -i locales/mybot.pot -d locales -D mybot -l uk
Step 3: translate texts located in locales/{language}/LC_MESSAGES/mybot.po
To open .po file you can use basic text editor or any PO editor, e.g. https://poedit.net/
Step 4: compile translations
# pybabel compile -d locales -D mybot
@ -27,7 +32,8 @@ Step 5: When you change the code of your bot you need to update po & mo files.
command from step 1
Step 5.2: update po files
# pybabel update -d locales -D mybot -i locales/mybot.pot
Step 5.3: update your translations
Step 5.3: update your translations
location and tools you know from step 3
Step 5.4: compile mo files
command from step 4
"""
@ -92,5 +98,6 @@ async def cmd_like(message: types.Message, locale):
# NOTE: This is comment for a translator
await message.reply(__('Aiogram has {number} like!', 'Aiogram has {number} likes!', likes).format(number=likes))
if __name__ == '__main__':
executor.start_polling(dp, skip_updates=True)

View file

@ -1,12 +1,15 @@
from typing import Set
from datetime import datetime
import pytest
from aiogram.dispatcher.filters.builtin import (
Text,
extract_chat_ids,
ChatIDArgumentType,
ChatIDArgumentType, ForwardedMessageFilter, IDFilter,
)
from aiogram.types import Message
from tests.types.dataset import MESSAGE, MESSAGE_FROM_CHANNEL
class TestText:
@ -69,3 +72,39 @@ class TestText:
)
def test_extract_chat_ids(chat_id: ChatIDArgumentType, expected: Set[int]):
assert extract_chat_ids(chat_id) == expected
class TestForwardedMessageFilter:
@pytest.mark.asyncio
async def test_filter_forwarded_messages(self):
filter = ForwardedMessageFilter(is_forwarded=True)
forwarded_message = Message(forward_date=round(datetime(2020, 5, 21, 5, 1).timestamp()), **MESSAGE)
not_forwarded_message = Message(**MESSAGE)
assert await filter.check(forwarded_message)
assert not await filter.check(not_forwarded_message)
@pytest.mark.asyncio
async def test_filter_not_forwarded_messages(self):
filter = ForwardedMessageFilter(is_forwarded=False)
forwarded_message = Message(forward_date=round(datetime(2020, 5, 21, 5, 1).timestamp()), **MESSAGE)
not_forwarded_message = Message(**MESSAGE)
assert await filter.check(not_forwarded_message)
assert not await filter.check(forwarded_message)
class TestIDFilter:
@pytest.mark.asyncio
async def test_chat_id_for_channels(self):
message_from_channel = Message(**MESSAGE_FROM_CHANNEL)
filter = IDFilter(chat_id=message_from_channel.chat.id)
assert await filter.check(message_from_channel)

View file

@ -0,0 +1,66 @@
import functools
import pytest
from aiogram.dispatcher.handler import Handler, _check_spec, _get_spec
def callback1(foo: int, bar: int, baz: int):
return locals()
async def callback2(foo: int, bar: int, baz: int):
return locals()
async def callback3(foo: int, **kwargs):
return locals()
class TestHandlerObj:
def test_init_decorated(self):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorator
def callback1(foo, bar, baz):
pass
@decorator
@decorator
def callback2(foo, bar, baz):
pass
obj1 = Handler.HandlerObj(callback1, _get_spec(callback1))
obj2 = Handler.HandlerObj(callback2, _get_spec(callback2))
assert set(obj1.spec.args) == {"foo", "bar", "baz"}
assert obj1.handler == callback1
assert set(obj2.spec.args) == {"foo", "bar", "baz"}
assert obj2.handler == callback2
@pytest.mark.parametrize(
"callback,kwargs,result",
[
pytest.param(
callback1, {"foo": 42, "spam": True, "baz": "fuz"}, {"foo": 42, "baz": "fuz"}
),
pytest.param(
callback2,
{"foo": 42, "spam": True, "baz": "fuz", "bar": "test"},
{"foo": 42, "baz": "fuz", "bar": "test"},
),
pytest.param(
callback3,
{"foo": 42, "spam": True, "baz": "fuz", "bar": "test"},
{"foo": 42, "spam": True, "baz": "fuz", "bar": "test"},
),
],
)
def test__check_spec(self, callback, kwargs, result):
spec = _get_spec(callback)
assert _check_spec(spec, kwargs) == result

View file

@ -0,0 +1,14 @@
import pytest
from aiogram.utils.deprecated import DeprecatedReadOnlyClassVar
def test_DeprecatedReadOnlyClassVarCD():
assert DeprecatedReadOnlyClassVar.__slots__ == ("_new_value_getter", "_warning_message")
new_value_of_deprecated_cls_cd = "mpa"
pseudo_owner_cls = type("OpekaCla$$", (), {})
deprecated_cd = DeprecatedReadOnlyClassVar("mopekaa", lambda owner: new_value_of_deprecated_cls_cd)
with pytest.warns(DeprecationWarning):
assert deprecated_cd.__get__(None, pseudo_owner_cls) == new_value_of_deprecated_cls_cd

View file

@ -0,0 +1,11 @@
import pytest
from aiogram.utils import markdown
class TestMarkdownEscape:
def test_equality_sign_is_escaped(self):
assert markdown.escape_md(r"e = mc2") == r"e \= mc2"
def test_pre_escaped(self):
assert markdown.escape_md(r"hello\.") == r"hello\\\."

View file

@ -0,0 +1,25 @@
from aiogram.types import MessageEntity, MessageEntityType
from aiogram.utils import text_decorations
class TestTextDecorations:
def test_unparse_entities_normal_text(self):
assert text_decorations.markdown_decoration.unparse(
"hi i'm bold and italic and still bold",
entities=[
MessageEntity(offset=3, length=34, type=MessageEntityType.BOLD),
MessageEntity(offset=12, length=10, type=MessageEntityType.ITALIC),
]
) == "hi *i'm bold _and italic_\r and still bold*"
def test_unparse_entities_emoji_text(self):
"""
emoji is encoded as two chars in json
"""
assert text_decorations.markdown_decoration.unparse(
"🚀 i'm bold and italic and still bold",
entities=[
MessageEntity(offset=3, length=34, type=MessageEntityType.BOLD),
MessageEntity(offset=12, length=10, type=MessageEntityType.ITALIC),
]
) == "🚀 *i'm bold _and italic_\r and still bold*"

View file

@ -409,6 +409,20 @@ MESSAGE_WITH_VOICE = {
"voice": VOICE,
}
CHANNEL = {
"type": "channel",
"username": "best_channel_ever",
"id": -1001065170817,
}
MESSAGE_FROM_CHANNEL = {
"message_id": 123432,
"from": None,
"chat": CHANNEL,
"date": 1508768405,
"text": "Hi, world!",
}
PRE_CHECKOUT_QUERY = {
"id": "262181558630368727",
"from": USER,
@ -457,3 +471,8 @@ WEBHOOK_INFO = {
"has_custom_certificate": False,
"pending_update_count": 0,
}
REPLY_KEYBOARD_MARKUP = {
"keyboard": [[{"text": "something here"}]],
"resize_keyboard": True,
}

View file

@ -0,0 +1,42 @@
from aiogram import types
from .dataset import AUDIO, ANIMATION, \
DOCUMENT, PHOTO, VIDEO
WIDTH = 'width'
HEIGHT = 'height'
input_media_audio = types.InputMediaAudio(
types.Audio(**AUDIO))
input_media_animation = types.InputMediaAnimation(
types.Animation(**ANIMATION))
input_media_document = types.InputMediaDocument(
types.Document(**DOCUMENT))
input_media_video = types.InputMediaVideo(
types.Video(**VIDEO))
input_media_photo = types.InputMediaPhoto(
types.PhotoSize(**PHOTO))
def test_field_width():
"""
https://core.telegram.org/bots/api#inputmedia
"""
assert not hasattr(input_media_audio, WIDTH)
assert not hasattr(input_media_document, WIDTH)
assert not hasattr(input_media_photo, WIDTH)
assert hasattr(input_media_animation, WIDTH)
assert hasattr(input_media_video, WIDTH)
def test_field_height():
"""
https://core.telegram.org/bots/api#inputmedia
"""
assert not hasattr(input_media_audio, HEIGHT)
assert not hasattr(input_media_document, HEIGHT)
assert not hasattr(input_media_photo, HEIGHT)
assert hasattr(input_media_animation, HEIGHT)
assert hasattr(input_media_video, HEIGHT)

View file

@ -0,0 +1,12 @@
from aiogram import types
from .dataset import REPLY_KEYBOARD_MARKUP
reply_keyboard = types.ReplyKeyboardMarkup(**REPLY_KEYBOARD_MARKUP)
def test_serialize():
assert reply_keyboard.to_python() == REPLY_KEYBOARD_MARKUP
def test_deserialize():
assert reply_keyboard.to_object(reply_keyboard.to_python()) == reply_keyboard