Merge branch 'dev-2.x'

This commit is contained in:
Alex Root Junior 2020-06-13 21:30:57 +03:00
commit 866011ab2b
26 changed files with 473 additions and 164 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

@ -1,3 +1,8 @@
import sys
if sys.version_info < (3, 7):
raise ImportError('Your Python version {0} is not supported by aiogram, please install '
'Python 3.7+'.format('.'.join(map(str, sys.version_info[:3]))))
import asyncio
import os
@ -38,5 +43,5 @@ __all__ = [
'utils'
]
__version__ = '2.8'
__api_version__ = '4.8'
__version__ = '2.9.1'
__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

@ -5,7 +5,7 @@ import ssl
import typing
import warnings
from contextvars import ContextVar
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Union, Type
import aiohttp
import certifi
@ -61,6 +61,7 @@ class BaseBot:
api.check_token(token)
self._token = None
self.__token = token
self.id = int(token.split(sep=':')[0])
self.proxy = proxy
self.proxy_auth = proxy_auth
@ -73,6 +74,12 @@ class BaseBot:
# aiohttp main session
ssl_context = ssl.create_default_context(cafile=certifi.where())
self._session: Optional[aiohttp.ClientSession] = None
self._connector_class: Type[aiohttp.TCPConnector] = aiohttp.TCPConnector
self._connector_init = dict(
limit=connections_limit, ssl=ssl_context, loop=self.loop
)
if isinstance(proxy, str) and (proxy.startswith('socks5://') or proxy.startswith('socks4://')):
from aiohttp_socks import SocksConnector
from aiohttp_socks.utils import parse_proxy_url
@ -84,30 +91,31 @@ class BaseBot:
if not password:
password = proxy_auth.password
connector = SocksConnector(socks_ver=socks_ver, host=host, port=port,
username=username, password=password,
limit=connections_limit, ssl_context=ssl_context,
rdns=True, loop=self.loop)
self._connector_class = SocksConnector
self._connector_init.update(
socks_ver=socks_ver, host=host, port=port,
username=username, password=password, rdns=True,
)
self.proxy = None
self.proxy_auth = None
else:
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
def __del__(self):
if not hasattr(self, 'loop') or not hasattr(self, 'session'):
return
if self.loop.is_running():
self.loop.create_task(self.close())
return
loop = asyncio.new_event_loop()
loop.run_until_complete(self.close())
def get_new_session(self) -> aiohttp.ClientSession:
return aiohttp.ClientSession(
connector=self._connector_class(**self._connector_init),
loop=self.loop,
json_serialize=json.dumps
)
@property
def session(self) -> Optional[aiohttp.ClientSession]:
if self._session is None or self._session.closed:
self._session = self.get_new_session()
return self._session
@staticmethod
def _prepare_timeout(

View file

@ -10,7 +10,7 @@ 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
RegexpCommandsFilter, StateFilter, Text, IDFilter, AdminFilter, IsReplyFilter, ForwardedMessageFilter
from .filters.builtin import IsSenderContact
from .handler import Handler
from .middlewares import MiddlewareManager
@ -160,6 +160,12 @@ 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
])
def __del__(self):
self.stop_polling()
@ -236,6 +242,7 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
if update.poll:
return await self.poll_handlers.notify(update.poll)
if 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:
err = await self.errors_handlers.notify(update, e)

View file

@ -1,6 +1,6 @@
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
from .factory import FiltersFactory
from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, execute_filter, \
check_filters, get_filter_spec, get_filters_spec
@ -32,4 +32,5 @@ __all__ = [
'get_filters_spec',
'execute_filter',
'check_filters',
'ForwardedMessageFilter',
]

View file

@ -12,6 +12,19 @@ from aiogram.dispatcher.filters.filters import BoundFilter, Filter
from aiogram.types import CallbackQuery, Message, InlineQuery, Poll, ChatType
ChatIDArgumentType = typing.Union[typing.Iterable[typing.Union[int, str]], str, int]
def extract_chat_ids(chat_id: ChatIDArgumentType) -> typing.Set[int]:
# since "str" is also an "Iterable", we have to check for it first
if isinstance(chat_id, str):
return {int(chat_id), }
if isinstance(chat_id, Iterable):
return {int(item) for (item) in chat_id}
# the last possible type is a single "int"
return {chat_id, }
class Command(Filter):
"""
You can handle commands by using this filter.
@ -545,10 +558,9 @@ class ExceptionsFilter(BoundFilter):
class IDFilter(Filter):
def __init__(self,
user_id: Optional[Union[Iterable[Union[int, str]], str, int]] = None,
chat_id: Optional[Union[Iterable[Union[int, str]], str, int]] = None,
user_id: Optional[ChatIDArgumentType] = None,
chat_id: Optional[ChatIDArgumentType] = None,
):
"""
:param user_id:
@ -557,18 +569,14 @@ class IDFilter(Filter):
if user_id is None and chat_id is None:
raise ValueError("Both user_id and chat_id can't be None")
self.user_id = None
self.chat_id = None
self.user_id: Optional[typing.Set[int]] = None
self.chat_id: Optional[typing.Set[int]] = None
if user_id:
if isinstance(user_id, Iterable):
self.user_id = list(map(int, user_id))
else:
self.user_id = [int(user_id), ]
self.user_id = extract_chat_ids(user_id)
if chat_id:
if isinstance(chat_id, Iterable):
self.chat_id = list(map(int, chat_id))
else:
self.chat_id = [int(chat_id), ]
self.chat_id = extract_chat_ids(chat_id)
@classmethod
def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]:
@ -614,22 +622,20 @@ class AdminFilter(Filter):
is_chat_admin is required for InlineQuery.
"""
def __init__(self, is_chat_admin: Optional[Union[Iterable[Union[int, str]], str, int, bool]] = None):
def __init__(self, is_chat_admin: Optional[Union[ChatIDArgumentType, bool]] = None):
self._check_current = False
self._chat_ids = None
if is_chat_admin is False:
raise ValueError("is_chat_admin cannot be False")
if is_chat_admin:
if isinstance(is_chat_admin, bool):
self._check_current = is_chat_admin
if isinstance(is_chat_admin, Iterable):
self._chat_ids = list(is_chat_admin)
else:
self._chat_ids = [is_chat_admin]
else:
if not is_chat_admin:
self._check_current = True
return
if isinstance(is_chat_admin, bool):
self._check_current = is_chat_admin
self._chat_ids = extract_chat_ids(is_chat_admin)
@classmethod
def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]:
@ -675,3 +681,13 @@ 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

View file

@ -178,7 +178,7 @@ class Chat(base.TelegramObject):
:return: Returns True on success.
:rtype: :obj:`base.Boolean`
"""
return await self.bot.delete_chat_description(self.id, description)
return await self.bot.set_chat_description(self.id, description)
async def kick(self, user_id: base.Integer,
until_date: typing.Union[base.Integer, datetime.datetime, datetime.timedelta, None] = None) -> base.Boolean:

View file

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

View file

@ -92,12 +92,13 @@ class InlineQueryResultPhoto(InlineQueryResult):
title: typing.Optional[base.String] = None,
description: typing.Optional[base.String] = None,
caption: typing.Optional[base.String] = None,
parse_mode: typing.Optional[base.String] = None,
reply_markup: typing.Optional[InlineKeyboardMarkup] = None,
input_message_content: typing.Optional[InputMessageContent] = None):
super(InlineQueryResultPhoto, self).__init__(id=id, photo_url=photo_url, thumb_url=thumb_url,
photo_width=photo_width, photo_height=photo_height, title=title,
description=description, caption=caption,
reply_markup=reply_markup,
parse_mode=parse_mode, reply_markup=reply_markup,
input_message_content=input_message_content)
@ -117,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)
@ -156,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

@ -137,8 +137,6 @@ class InputMediaAudio(InputMedia):
https://core.telegram.org/bots/api#inputmediaanimation
"""
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)

View file

@ -51,6 +51,7 @@ class Message(base.TelegramObject):
forward_signature: base.String = fields.Field()
forward_date: datetime.datetime = fields.DateTimeField()
reply_to_message: Message = fields.Field(base='Message')
via_bot: User = fields.Field(base=User)
edit_date: datetime.datetime = fields.DateTimeField()
media_group_id: base.String = fields.Field()
author_signature: base.String = fields.Field()
@ -167,7 +168,8 @@ class Message(base.TelegramObject):
:return: tuple of (command, args)
"""
if self.is_command():
command, _, args = self.text.partition(' ')
command, *args = self.text.split(maxsplit=1)
args = args[0] if args else None
return command, args
def get_command(self, pure=False):
@ -191,7 +193,7 @@ class Message(base.TelegramObject):
"""
command = self.get_full_command()
if command:
return command[1].strip()
return command[1]
def parse_entities(self, as_html=True):
"""

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

@ -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(
value=html_decoration.quote(_join(*content, sep=sep))
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(
value=html_decoration.quote(_join(*content, sep=sep))
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(
value=html_decoration.quote(_join(*content, sep=sep))
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(
value=html_decoration.quote(_join(*content, sep=sep))
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=html_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,22 +55,22 @@ 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 []
)
)
return remove_surrogate(result)
return result
def _unparse_entities(
self,
text: str,
entities: Iterable[MessageEntity],
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):
@ -88,7 +82,7 @@ class TextDecoration:
offset = entity.offset + entity.length
sub_entities = list(
filter(lambda e: e.offset < offset, entities[index + 1 :])
filter(lambda e: e.offset < (offset or 0), entities[index + 1 :])
)
yield self.apply_entity(
entity,
@ -102,42 +96,102 @@ class TextDecoration:
if offset < length:
yield self.quote(text[offset:length])
@abstractmethod
def link(self, value: str, link: 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,
)
@abstractmethod
def bold(self, value: str) -> str: # pragma: no cover
pass
MARKDOWN_QUOTE_PATTERN = re.compile(r"([_*\[\]()~`>#+\-=|{}.!])")
@abstractmethod
def italic(self, value: str) -> str: # pragma: no cover
pass
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
),
)
@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
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 HtmlDecoration(TextDecoration):
def link(self, value: str, link: str) -> str:
return f'<a href="{link}">{value}</a>'
def bold(self, value: str) -> str:
return f"<b>{value}</b>"
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)
def remove_surrogate(text: str) -> str:
return text.encode("utf-16", "surrogatepass").decode("utf-16")
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)
html_decoration = HtmlDecoration()
markdown_decoration = MarkdownDecoration()

View file

@ -3,7 +3,7 @@
ujson>=1.35
python-rapidjson>=0.7.0
emoji>=0.5.2
pytest>=4.4.1,<4.6
pytest>=5.4
pytest-asyncio>=0.10.0
tox>=3.9.0
aresponses>=1.1.1

View file

@ -141,6 +141,14 @@ IsReplyFilter
:show-inheritance:
ForwardedMessageFilter
-------------
.. autoclass:: aiogram.dispatcher.filters.filters.ForwardedMessageFilter
: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

@ -2,7 +2,8 @@ import aresponses
from aiogram import Bot
TOKEN = '123456789:AABBCCDDEEFFaabbccddeeff-1234567890'
BOT_ID = 123456789
TOKEN = f'{BOT_ID}:AABBCCDDEEFFaabbccddeeff-1234567890'
class FakeTelegram(aresponses.ResponsesMockServer):

View file

@ -1,7 +1,7 @@
import pytest
from aiogram import Bot, types
from . import FakeTelegram, TOKEN
from . import FakeTelegram, TOKEN, BOT_ID
pytestmark = pytest.mark.asyncio
@ -525,3 +525,9 @@ async def test_set_sticker_set_thumb(bot: Bot, event_loop):
result = await bot.set_sticker_set_thumb(name='test', user_id=123456789, thumb='file_id')
assert isinstance(result, bool)
assert result is True
async def test_bot_id(bot: Bot):
""" Check getting id from token. """
bot = Bot(TOKEN)
assert bot.id == BOT_ID # BOT_ID is a correct id from TOKEN

View file

@ -0,0 +1,61 @@
import aiohttp
import aiohttp_socks
import pytest
from aiogram.bot.base import BaseBot
try:
from asynctest import CoroutineMock, patch
except ImportError:
from unittest.mock import AsyncMock as CoroutineMock, patch # type: ignore
class TestAiohttpSession:
@pytest.mark.asyncio
async def test_create_bot(self):
bot = BaseBot(token="42:correct")
assert bot._session is None
assert isinstance(bot._connector_init, dict)
assert all(key in {"limit", "ssl", "loop"} for key in bot._connector_init)
assert isinstance(bot._connector_class, type)
assert issubclass(bot._connector_class, aiohttp.TCPConnector)
assert bot._session is None
assert isinstance(bot.session, aiohttp.ClientSession)
assert bot.session == bot._session
@pytest.mark.asyncio
async def test_create_proxy_bot(self):
socks_ver, host, port, username, password = (
"socks5", "124.90.90.90", 9999, "login", "password"
)
bot = BaseBot(
token="42:correct",
proxy=f"{socks_ver}://{host}:{port}/",
proxy_auth=aiohttp.BasicAuth(username, password, "encoding"),
)
assert bot._connector_class == aiohttp_socks.SocksConnector
assert isinstance(bot._connector_init, dict)
init_kwargs = bot._connector_init
assert init_kwargs["username"] == username
assert init_kwargs["password"] == password
assert init_kwargs["host"] == host
assert init_kwargs["port"] == port
@pytest.mark.asyncio
async def test_close_session(self):
bot = BaseBot(token="42:correct",)
aiohttp_client_0 = bot.session
with patch("aiohttp.ClientSession.close", new=CoroutineMock()) as mocked_close:
await aiohttp_client_0.close()
mocked_close.assert_called_once()
await aiohttp_client_0.close()
assert aiohttp_client_0 != bot.session # will create new session

View file

@ -1,6 +1,15 @@
from typing import Set
from datetime import datetime
import pytest
from aiogram.dispatcher.filters.builtin import Text
from aiogram.dispatcher.filters.builtin import (
Text,
extract_chat_ids,
ChatIDArgumentType, ForwardedMessageFilter,
)
from aiogram.types import Message
from tests.types.dataset import MESSAGE
class TestText:
@ -16,3 +25,72 @@ class TestText:
config = {param: value}
res = Text.validate(config)
assert res == {key: value}
@pytest.mark.parametrize(
('chat_id', 'expected'),
(
pytest.param('-64856280', {-64856280,}, id='single negative int as string'),
pytest.param('64856280', {64856280,}, id='single positive int as string'),
pytest.param(-64856280, {-64856280,}, id='single negative int'),
pytest.param(64856280, {64856280,}, id='single positive negative int'),
pytest.param(
['-64856280'], {-64856280,}, id='list of single negative int as string'
),
pytest.param([-64856280], {-64856280,}, id='list of single negative int'),
pytest.param(
['-64856280', '-64856280'],
{-64856280,},
id='list of two duplicated negative ints as strings',
),
pytest.param(
['-64856280', -64856280],
{-64856280,},
id='list of one negative int as string and one negative int',
),
pytest.param(
[-64856280, -64856280],
{-64856280,},
id='list of two duplicated negative ints',
),
pytest.param(
iter(['-64856280']),
{-64856280,},
id='iterator from a list of single negative int as string',
),
pytest.param(
[10000000, 20000000, 30000000],
{10000000, 20000000, 30000000},
id='list of several positive ints',
),
pytest.param(
[10000000, '20000000', -30000000],
{10000000, 20000000, -30000000},
id='list of positive int, positive int as string, negative int',
),
),
)
def test_extract_chat_ids(chat_id: ChatIDArgumentType, expected: Set[int]):
assert extract_chat_ids(chat_id) == expected
class TestForwardedMessageFilter:
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)
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)

View file

@ -457,3 +457,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