Merge branch 'dev-2.x'

This commit is contained in:
Alex Root Junior 2021-04-28 23:41:20 +03:00
commit 88e3d0503d
26 changed files with 417 additions and 150 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-5.1-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api)
[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.2-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-5.1-blue.svg?style=flat-square&logo=telegram
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-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.12.1'
__api_version__ = '5.1'
__version__ = '2.13'
__api_version__ = '5.2'

View file

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

View file

@ -1484,19 +1484,36 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
async def send_chat_action(self, chat_id: typing.Union[base.Integer, base.String],
action: base.String) -> base.Boolean:
"""
Use this method when you need to tell the user that something is happening on the bot's side.
The status is set for 5 seconds or less
(when a message arrives from your bot, Telegram clients clear its typing status).
Use this method when you need to tell the user that something is
happening on the bot's side. The status is set for 5 seconds or
less (when a message arrives from your bot, Telegram clients
clear its typing status). Returns True on success.
We only recommend using this method when a response from the bot will take
a noticeable amount of time to arrive.
Example: The ImageBot needs some time to process a request and
upload the image. Instead of sending a text message along the
lines of Retrieving image, please wait, the bot may use
sendChatAction with action = upload_photo. The user will see a
sending photo status for the bot.
We only recommend using this method when a response from the bot
will take a noticeable amount of time to arrive.
Source: https://core.telegram.org/bots/api#sendchataction
:param chat_id: Unique identifier for the target chat or username of the target channel
:param chat_id: Unique identifier for the target chat or
username of the target channel (in the format
@channelusername)
:type chat_id: :obj:`typing.Union[base.Integer, base.String]`
:param action: Type of action to broadcast
:param action: Type of action to broadcast. Choose one,
depending on what the user is about to receive: `typing` for
text messages, `upload_photo` for photos, `record_video` or
`upload_video` for videos, `record_voice` or `upload_voice`
for voice notes, `upload_document` for general files,
`find_location` for location data, `record_video_note` or
`upload_video_note` for video notes.
:type action: :obj:`base.String`
:return: Returns True on success
:rtype: :obj:`base.Boolean`
"""
@ -1834,7 +1851,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
expire_date = prepare_arg(expire_date)
payload = generate_payload(**locals())
return await self.request(api.Methods.CREATE_CHAT_INVITE_LINK, payload)
result = await self.request(api.Methods.CREATE_CHAT_INVITE_LINK, payload)
return types.ChatInviteLink(**result)
async def edit_chat_invite_link(self,
chat_id: typing.Union[base.Integer, base.String],
@ -1870,7 +1888,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
expire_date = prepare_arg(expire_date)
payload = generate_payload(**locals())
return await self.request(api.Methods.EDIT_CHAT_INVITE_LINK, payload)
result = await self.request(api.Methods.EDIT_CHAT_INVITE_LINK, payload)
return types.ChatInviteLink(**result)
async def revoke_chat_invite_link(self,
chat_id: typing.Union[base.Integer, base.String],
@ -1891,7 +1910,8 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
"""
payload = generate_payload(**locals())
return await self.request(api.Methods.REVOKE_CHAT_INVITE_LINK, payload)
result = await self.request(api.Methods.REVOKE_CHAT_INVITE_LINK, payload)
return types.ChatInviteLink(**result)
async def set_chat_photo(self, chat_id: typing.Union[base.Integer, base.String],
photo: base.InputFile) -> base.Boolean:
@ -2780,10 +2800,19 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
# === Payments ===
# https://core.telegram.org/bots/api#payments
async def send_invoice(self, chat_id: base.Integer, title: base.String,
description: base.String, payload: base.String,
provider_token: base.String, start_parameter: base.String,
currency: base.String, prices: typing.List[types.LabeledPrice],
async def send_invoice(self,
chat_id: typing.Union[base.Integer, base.String],
title: base.String,
description: base.String,
payload: base.String,
provider_token: base.String,
currency: base.String,
prices: typing.List[types.LabeledPrice],
max_tip_amount: typing.Optional[base.Integer] = None,
suggested_tip_amounts: typing.Optional[
typing.List[base.Integer]
] = None,
start_parameter: typing.Optional[base.String] = None,
provider_data: typing.Optional[typing.Dict] = None,
photo_url: typing.Optional[base.String] = None,
photo_size: typing.Optional[base.Integer] = None,
@ -2799,14 +2828,17 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
disable_notification: typing.Optional[base.Boolean] = None,
reply_to_message_id: typing.Optional[base.Integer] = None,
allow_sending_without_reply: typing.Optional[base.Boolean] = None,
reply_markup: typing.Optional[types.InlineKeyboardMarkup] = None) -> types.Message:
reply_markup: typing.Optional[types.InlineKeyboardMarkup] = None,
) -> types.Message:
"""
Use this method to send invoices.
Source: https://core.telegram.org/bots/api#sendinvoice
:param chat_id: Unique identifier for the target private chat
:type chat_id: :obj:`base.Integer`
:param chat_id: Unique identifier for the target chat or
username of the target channel (in the format
@channelusername)
:type chat_id: :obj:`typing.Union[base.Integer, base.String]`
:param title: Product name, 1-32 characters
:type title: :obj:`base.String`
@ -2821,10 +2853,6 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
:param provider_token: Payments provider token, obtained via Botfather
:type provider_token: :obj:`base.String`
:param start_parameter: Unique deep-linking parameter that can be used to generate this
invoice when used as a start parameter
:type start_parameter: :obj:`base.String`
:param currency: Three-letter ISO 4217 currency code, see more on currencies
:type currency: :obj:`base.String`
@ -2832,6 +2860,32 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
(e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.)
:type prices: :obj:`typing.List[types.LabeledPrice]`
:param max_tip_amount: The maximum accepted amount for tips in
the smallest units of the currency (integer, not
float/double). For example, for a maximum tip of US$ 1.45
pass max_tip_amount = 145. See the exp parameter in
currencies.json, it shows the number of digits past the
decimal point for each currency (2 for the majority of
currencies). Defaults to 0
:type max_tip_amount: :obj:`typing.Optional[base.Integer]`
:param suggested_tip_amounts: A JSON-serialized array of suggested
amounts of tips in the smallest units of the currency
(integer, not float/double). At most 4 suggested tip amounts
can be specified. The suggested tip amounts must be
positive, passed in a strictly increased order and must not
exceed max_tip_amount.
:type suggested_tip_amounts: :obj:`typing.Optional[typing.List[base.Integer]]`
:param start_parameter: Unique deep-linking parameter. If left
empty, forwarded copies of the sent message will have a Pay
button, allowing multiple users to pay directly from the
forwarded message, using the same invoice. If non-empty,
forwarded copies of the sent message will have a URL button
with a deep link to the bot (instead of a Pay button), with
the value used as the start parameter
:type start_parameter: :obj:`typing.Optional[base.String]`
:param provider_data: JSON-encoded data about the invoice, which will be shared with the payment provider
:type provider_data: :obj:`typing.Optional[typing.Dict]`
@ -2887,6 +2941,7 @@ class Bot(BaseBot, DataMixin, ContextInstanceMixin):
"""
prices = prepare_arg([price.to_python() if hasattr(price, 'to_python') else price for price in prices])
reply_markup = prepare_arg(reply_markup)
provider_data = prepare_arg(provider_data)
payload_ = generate_payload(**locals())
result = await self.request(api.Methods.SEND_INVOICE, payload_)

View file

@ -35,7 +35,7 @@ class MemoryStorage(BaseStorage):
user: typing.Union[str, int, None] = None,
default: typing.Optional[str] = None) -> typing.Optional[str]:
chat, user = self.resolve_address(chat=chat, user=user)
return self.data[chat][user]['state']
return self.data[chat][user].get("state", self.resolve_state(default))
async def get_data(self, *,
chat: typing.Union[str, int, None] = None,
@ -58,7 +58,7 @@ class MemoryStorage(BaseStorage):
user: typing.Union[str, int, None] = None,
state: typing.AnyStr = None):
chat, user = self.resolve_address(chat=chat, user=user)
self.data[chat][user]['state'] = state
self.data[chat][user]['state'] = self.resolve_state(state)
async def set_data(self, *,
chat: typing.Union[str, int, None] = None,

View file

@ -65,7 +65,7 @@ class MongoStorage(BaseStorage):
try:
self._mongo = AsyncIOMotorClient(self._uri)
except pymongo.errors.ConfigurationError as e:
if "query() got an unexpected keyword argument 'lifetime'" in e.args[0]:
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")
@ -114,7 +114,9 @@ class MongoStorage(BaseStorage):
async def wait_closed(self):
return True
async def set_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None,
async def set_state(self, *,
chat: Union[str, int, None] = None,
user: Union[str, int, None] = None,
state: Optional[AnyStr] = None):
chat, user = self.check_address(chat=chat, user=user)
db = await self.get_db()
@ -122,8 +124,11 @@ class MongoStorage(BaseStorage):
if state is None:
await db[STATE].delete_one(filter={'chat': chat, 'user': user})
else:
await db[STATE].update_one(filter={'chat': chat, 'user': user},
update={'$set': {'state': state}}, upsert=True)
await db[STATE].update_one(
filter={'chat': chat, 'user': user},
update={'$set': {'state': self.resolve_state(state)}},
upsert=True,
)
async def get_state(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None,
default: Optional[str] = None) -> Optional[str]:
@ -131,7 +136,7 @@ class MongoStorage(BaseStorage):
db = await self.get_db()
result = await db[STATE].find_one(filter={'chat': chat, 'user': user})
return result.get('state') if result else default
return result.get('state') if result else self.resolve_state(default)
async def set_data(self, *, chat: Union[str, int, None] = None, user: Union[str, int, None] = None,
data: Dict = None):

View file

@ -118,16 +118,19 @@ class RedisStorage(BaseStorage):
async def get_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
default: typing.Optional[str] = None) -> typing.Optional[str]:
record = await self.get_record(chat=chat, user=user)
return record['state']
return record.get('state', self.resolve_state(default))
async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
default: typing.Optional[str] = None) -> typing.Dict:
record = await self.get_record(chat=chat, user=user)
return record['data']
async def set_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
async def set_state(self, *,
chat: typing.Union[str, int, None] = None,
user: typing.Union[str, int, None] = None,
state: typing.Optional[typing.AnyStr] = None):
record = await self.get_record(chat=chat, user=user)
state = self.resolve_state(state)
await self.set_record(chat=chat, user=user, state=state, data=record['data'])
async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
@ -274,7 +277,7 @@ class RedisStorage2(BaseStorage):
chat, user = self.check_address(chat=chat, user=user)
key = self.generate_key(chat, user, STATE_KEY)
redis = await self.redis()
return await redis.get(key, encoding='utf8') or None
return await redis.get(key, encoding='utf8') or self.resolve_state(default)
async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
default: typing.Optional[dict] = None) -> typing.Dict:
@ -294,7 +297,7 @@ class RedisStorage2(BaseStorage):
if state is None:
await redis.delete(key)
else:
await redis.set(key, state, expire=self._state_ttl)
await redis.set(key, self.resolve_state(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):

View file

@ -95,7 +95,9 @@ class RethinkDBStorage(BaseStorage):
default: typing.Optional[str] = None) -> typing.Optional[str]:
chat, user = map(str, self.check_address(chat=chat, user=user))
async with self.connection() as conn:
return await r.table(self._table).get(chat)[user]['state'].default(default or None).run(conn)
return await r.table(self._table).get(chat)[user]['state'].default(
self.resolve_state(default) or None
).run(conn)
async def get_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
default: typing.Optional[str] = None) -> typing.Dict:
@ -103,11 +105,16 @@ class RethinkDBStorage(BaseStorage):
async with self.connection() as conn:
return await r.table(self._table).get(chat)[user]['data'].default(default or {}).run(conn)
async def set_state(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
async def set_state(self, *,
chat: typing.Union[str, int, None] = None,
user: typing.Union[str, int, None] = None,
state: typing.Optional[typing.AnyStr] = None):
chat, user = map(str, self.check_address(chat=chat, user=user))
async with self.connection() as conn:
await r.table(self._table).insert({'id': chat, user: {'state': state}}, conflict="update").run(conn)
await r.table(self._table).insert(
{'id': chat, user: {'state': self.resolve_state(state)}},
conflict="update",
).run(conn)
async def set_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None,
data: typing.Dict = None):

View file

@ -1,7 +1,7 @@
import gettext
import os
from contextvars import ContextVar
from typing import Any, Dict, Tuple
from typing import Any, Dict, Tuple, Optional
from babel import Locale
from babel.support import LazyProxy
@ -119,22 +119,24 @@ class I18nMiddleware(BaseMiddleware):
return LazyProxy(self.gettext, singular, plural, n, locale, enable_cache=enable_cache)
# noinspection PyMethodMayBeStatic,PyUnusedLocal
async def get_user_locale(self, action: str, args: Tuple[Any]) -> str:
async def get_user_locale(self, action: str, args: Tuple[Any]) -> Optional[str]:
"""
User locale getter
You can override the method if you want to use different way of getting user language.
You can override the method if you want to use different way of
getting user language.
:param action: event name
:param args: event arguments
:return: locale name
:return: locale name or None
"""
user: types.User = types.User.get_current()
locale: Locale = user.locale
user: Optional[types.User] = types.User.get_current()
locale: Optional[Locale] = user.locale if user else None
if locale:
*_, data = args
language = data['locale'] = locale.language
return language
return None
async def trigger(self, action, args):
"""

View file

@ -340,7 +340,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
limit=None,
reset_webhook=None,
fast: typing.Optional[bool] = True,
error_sleep: int = 5):
error_sleep: int = 5,
allowed_updates: typing.Optional[typing.List[str]] = None):
"""
Start long-polling
@ -349,6 +350,8 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
:param limit:
:param reset_webhook:
:param fast:
:param error_sleep:
:param allowed_updates:
:return:
"""
if self._polling:
@ -377,10 +380,15 @@ class Dispatcher(DataMixin, ContextInstanceMixin):
while self._polling:
try:
with self.bot.request_timeout(request_timeout):
updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout)
updates = await self.bot.get_updates(
limit=limit,
offset=offset,
timeout=timeout,
allowed_updates=allowed_updates
)
except asyncio.CancelledError:
break
except:
except Exception as e:
log.exception('Cause exception while getting updates.')
await asyncio.sleep(error_sleep)
continue

View file

@ -4,7 +4,7 @@ import typing
import warnings
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Optional, Union
from typing import Any, Dict, Iterable, List, Optional, Union
from babel.support import LazyProxy
@ -110,7 +110,8 @@ class Command(Filter):
if not text:
return False
full_command = text.split()[0]
full_command, *args_list = text.split(maxsplit=1)
args = args_list[0] if args_list else None
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():
@ -120,7 +121,7 @@ class Command(Filter):
if (command.lower() if ignore_case else command) not in commands:
return False
return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention)}
return {'command': cls.CommandObj(command=command, prefix=prefix, mention=mention, args=args)}
@dataclass
class CommandObj:

View file

@ -40,24 +40,27 @@ class BaseStorage:
@classmethod
def check_address(cls, *,
chat: typing.Union[str, int, None] = None,
user: typing.Union[str, int, None] = None) -> (typing.Union[str, int], typing.Union[str, int]):
user: typing.Union[str, int, None] = None,
) -> (typing.Union[str, int], typing.Union[str, int]):
"""
In all storage's methods chat or user is always required.
If one of them is not provided, you have to set missing value based on the provided one.
This method performs the check described above.
:param chat:
:param user:
:param chat: chat_id
:param user: user_id
:return:
"""
if chat is None and user is None:
raise ValueError('`user` or `chat` parameter is required but no one is provided!')
if user is None and chat is not None:
if user is None:
user = chat
elif user is not None and chat is None:
elif chat is None:
chat = user
return chat, user
async def get_state(self, *,
@ -270,6 +273,21 @@ class BaseStorage:
"""
await self.set_data(chat=chat, user=user, data={})
@staticmethod
def resolve_state(value):
from .filters.state import State
if value is None:
return
if isinstance(value, str):
return value
if isinstance(value, State):
return value.state
return str(value)
class FSMContext:
def __init__(self, storage, chat, user):
@ -279,20 +297,8 @@ class FSMContext:
def proxy(self):
return FSMContextProxy(self)
@staticmethod
def _resolve_state(value):
from .filters.state import State
if value is None:
return
elif isinstance(value, str):
return value
elif isinstance(value, State):
return value.state
return str(value)
async def get_state(self, default: typing.Optional[str] = None) -> typing.Optional[str]:
return await self.storage.get_state(chat=self.chat, user=self.user, default=self._resolve_state(default))
return await self.storage.get_state(chat=self.chat, user=self.user, default=default)
async def get_data(self, default: typing.Optional[str] = None) -> typing.Dict:
return await self.storage.get_data(chat=self.chat, user=self.user, default=default)
@ -301,7 +307,7 @@ class FSMContext:
await self.storage.update_data(chat=self.chat, user=self.user, data=data, **kwargs)
async def set_state(self, state: typing.Optional[typing.AnyStr] = None):
await self.storage.set_state(chat=self.chat, user=self.user, state=self._resolve_state(state))
await self.storage.set_state(chat=self.chat, user=self.user, state=state)
async def set_data(self, data: typing.Dict = None):
await self.storage.set_data(chat=self.chat, user=self.user, data=data)

View file

@ -619,7 +619,7 @@ class SendPhoto(BaseResponse, ReplyToMixin, DisableNotificationMixin):
a photo that exists on the Telegram servers (recommended), pass an HTTP URL as a String for
Telegram to get a photo from the Internet, or upload a new photo using multipart/form-data.
:param caption: String (Optional) - Photo caption (may also be used when resending photos by file_id),
0-200 characters
0-1024 characters after entities parsing
:param disable_notification: Boolean (Optional) - Sends the message silently. Users will receive
a notification with no sound.
:param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message
@ -672,7 +672,7 @@ class SendAudio(BaseResponse, ReplyToMixin, DisableNotificationMixin):
to send an audio file that exists on the Telegram servers (recommended), pass an HTTP URL
as a String for Telegram to get an audio file from the Internet, or upload a new one
using multipart/form-data.
:param caption: String (Optional) - Audio caption, 0-200 characters
:param caption: String (Optional) - Audio caption, 0-1024 characters after entities parsing
:param duration: Integer (Optional) - Duration of the audio in seconds
:param performer: String (Optional) - Performer
:param title: String (Optional) - Track name
@ -731,7 +731,7 @@ class SendDocument(BaseResponse, ReplyToMixin, DisableNotificationMixin):
as a String for Telegram to get a file from the Internet, or upload a new one
using multipart/form-data.
:param caption: String (Optional) - Document caption
(may also be used when resending documents by file_id), 0-200 characters
(may also be used when resending documents by file_id), 0-1024 characters after entities parsing
:param disable_notification: Boolean (Optional) - Sends the message silently.
Users will receive a notification with no sound.
:param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message
@ -788,7 +788,7 @@ class SendVideo(BaseResponse, ReplyToMixin, DisableNotificationMixin):
:param width: Integer (Optional) - Video width
:param height: Integer (Optional) - Video height
:param caption: String (Optional) - Video caption (may also be used when resending videos by file_id),
0-200 characters
0-1024 characters after entities parsing
:param disable_notification: Boolean (Optional) - Sends the message silently.
Users will receive a notification with no sound.
:param reply_to_message_id: Integer (Optional) - If the message is a reply, ID of the original message
@ -845,7 +845,7 @@ class SendVoice(BaseResponse, ReplyToMixin, DisableNotificationMixin):
to send a file that exists on the Telegram servers (recommended), pass an HTTP URL
as a String for Telegram to get a file from the Internet, or upload a new one
using multipart/form-data.
:param caption: String (Optional) - Voice message caption, 0-200 characters
:param caption: String (Optional) - Voice message caption, 0-1024 characters after entities parsing
:param duration: Integer (Optional) - Duration of the voice message in seconds
:param disable_notification: Boolean (Optional) - Sends the message silently.
Users will receive a notification with no sound.

View file

@ -35,7 +35,7 @@ from .input_file import InputFile
from .input_media import InputMedia, InputMediaAnimation, InputMediaAudio, InputMediaDocument, InputMediaPhoto, \
InputMediaVideo, MediaGroup
from .input_message_content import InputContactMessageContent, InputLocationMessageContent, InputMessageContent, \
InputTextMessageContent, InputVenueMessageContent
InputTextMessageContent, InputVenueMessageContent, InputInvoiceMessageContent
from .invoice import Invoice
from .labeled_price import LabeledPrice
from .location import Location
@ -72,6 +72,7 @@ from .video_note import VideoNote
from .voice import Voice
from .voice_chat_ended import VoiceChatEnded
from .voice_chat_participants_invited import VoiceChatParticipantsInvited
from .voice_chat_scheduled import VoiceChatScheduled
from .voice_chat_started import VoiceChatStarted
from .webhook_info import WebhookInfo
@ -131,6 +132,7 @@ __all__ = (
'InlineQueryResultVideo',
'InlineQueryResultVoice',
'InputContactMessageContent',
'InputInvoiceMessageContent',
'InputFile',
'InputLocationMessageContent',
'InputMedia',
@ -191,6 +193,7 @@ __all__ = (
'Voice',
'VoiceChatEnded',
'VoiceChatParticipantsInvited',
'VoiceChatScheduled',
'VoiceChatStarted',
'WebhookInfo',
'base',

View file

@ -732,6 +732,8 @@ class ChatActions(helper.Helper):
UPLOAD_VIDEO: str = helper.Item() # upload_video
RECORD_AUDIO: str = helper.Item() # record_audio
UPLOAD_AUDIO: str = helper.Item() # upload_audio
RECORD_VOICE: str = helper.Item() # record_voice
UPLOAD_VOICE: str = helper.Item() # upload_voice
UPLOAD_DOCUMENT: str = helper.Item() # upload_document
FIND_LOCATION: str = helper.Item() # find_location
RECORD_VIDEO_NOTE: str = helper.Item() # record_video_note
@ -817,6 +819,26 @@ class ChatActions(helper.Helper):
"""
await cls._do(cls.UPLOAD_AUDIO, sleep)
@classmethod
async def record_voice(cls, sleep=None):
"""
Do record voice
:param sleep: sleep timeout
:return:
"""
await cls._do(cls.RECORD_VOICE, sleep)
@classmethod
async def upload_voice(cls, sleep=None):
"""
Do upload voice
:param sleep: sleep timeout
:return:
"""
await cls._do(cls.UPLOAD_VOICE, sleep)
@classmethod
async def upload_document(cls, sleep=None):
"""

View file

@ -17,9 +17,10 @@ class InlineQuery(base.TelegramObject):
"""
id: base.String = fields.Field()
from_user: User = fields.Field(alias='from', base=User)
location: Location = fields.Field(base=Location)
query: base.String = fields.Field()
offset: base.String = fields.Field()
chat_type: base.String = fields.Field()
location: Location = fields.Field(base=Location)
async def answer(self,
results: typing.List[InlineQueryResult],

View file

@ -3,6 +3,8 @@ import typing
from . import base
from . import fields
from .message_entity import MessageEntity
from .labeled_price import LabeledPrice
from ..utils.payload import generate_payload
class InputMessageContent(base.TelegramObject):
@ -44,6 +46,66 @@ class InputContactMessageContent(InputMessageContent):
)
class InputInvoiceMessageContent(InputMessageContent):
"""
Represents the content of an invoice message to be sent as the
result of an inline query.
https://core.telegram.org/bots/api#inputinvoicemessagecontent
"""
title: base.String = fields.Field()
description: base.String = fields.Field()
payload: base.String = fields.Field()
provider_token: base.String = fields.Field()
currency: base.String = fields.Field()
prices: typing.List[LabeledPrice] = fields.ListField(base=LabeledPrice)
max_tip_amount: typing.Optional[base.Integer] = fields.Field()
suggested_tip_amounts: typing.Optional[
typing.List[base.Integer]
] = fields.ListField(base=base.Integer)
provider_data: typing.Optional[base.String] = fields.Field()
photo_url: typing.Optional[base.String] = fields.Field()
photo_size: typing.Optional[base.Integer] = fields.Field()
photo_width: typing.Optional[base.Integer] = fields.Field()
photo_height: typing.Optional[base.Integer] = fields.Field()
need_name: typing.Optional[base.Boolean] = fields.Field()
need_phone_number: typing.Optional[base.Boolean] = fields.Field()
need_email: typing.Optional[base.Boolean] = fields.Field()
need_shipping_address: typing.Optional[base.Boolean] = fields.Field()
send_phone_number_to_provider: typing.Optional[base.Boolean] = fields.Field()
send_email_to_provider: typing.Optional[base.Boolean] = fields.Field()
is_flexible: typing.Optional[base.Boolean] = fields.Field()
def __init__(
self,
title: base.String,
description: base.String,
payload: base.String,
provider_token: base.String,
currency: base.String,
prices: typing.List[LabeledPrice] = None,
max_tip_amount: typing.Optional[base.Integer] = None,
suggested_tip_amounts: typing.Optional[typing.List[base.Integer]] = None,
provider_data: typing.Optional[base.String] = None,
photo_url: typing.Optional[base.String] = None,
photo_size: typing.Optional[base.Integer] = None,
photo_width: typing.Optional[base.Integer] = None,
photo_height: typing.Optional[base.Integer] = None,
need_name: typing.Optional[base.Boolean] = None,
need_phone_number: typing.Optional[base.Boolean] = None,
need_email: typing.Optional[base.Boolean] = None,
need_shipping_address: typing.Optional[base.Boolean] = None,
send_phone_number_to_provider: typing.Optional[base.Boolean] = None,
send_email_to_provider: typing.Optional[base.Boolean] = None,
is_flexible: typing.Optional[base.Boolean] = None,
):
if prices is None:
prices = []
payload = generate_payload(**locals())
super().__init__(**payload)
class InputLocationMessageContent(InputMessageContent):
"""
Represents the content of a location message to be sent as the result of an inline query.

View file

@ -34,6 +34,7 @@ from .video_note import VideoNote
from .voice import Voice
from .voice_chat_ended import VoiceChatEnded
from .voice_chat_participants_invited import VoiceChatParticipantsInvited
from .voice_chat_scheduled import VoiceChatScheduled
from .voice_chat_started import VoiceChatStarted
from ..utils import helper
from ..utils import markdown as md
@ -98,6 +99,7 @@ class Message(base.TelegramObject):
connected_website: base.String = fields.Field()
passport_data: PassportData = fields.Field(base=PassportData)
proximity_alert_triggered: ProximityAlertTriggered = fields.Field(base=ProximityAlertTriggered)
voice_chat_scheduled: VoiceChatScheduled = fields.Field(base=VoiceChatScheduled)
voice_chat_started: VoiceChatStarted = fields.Field(base=VoiceChatStarted)
voice_chat_ended: VoiceChatEnded = fields.Field(base=VoiceChatEnded)
voice_chat_participants_invited: VoiceChatParticipantsInvited = fields.Field(base=VoiceChatParticipantsInvited)
@ -166,6 +168,8 @@ class Message(base.TelegramObject):
return ContentType.PASSPORT_DATA
if self.proximity_alert_triggered:
return ContentType.PROXIMITY_ALERT_TRIGGERED
if self.voice_chat_scheduled:
return ContentType.VOICE_CHAT_SCHEDULED
if self.voice_chat_started:
return ContentType.VOICE_CHAT_STARTED
if self.voice_chat_ended:
@ -267,7 +271,8 @@ class Message(base.TelegramObject):
:return: str
"""
if ChatType.is_private(self.chat):
if self.chat.type == ChatType.PRIVATE:
raise TypeError("Invalid chat type!")
url = "https://t.me/"
if self.chat.username:
@ -461,7 +466,7 @@ class Message(base.TelegramObject):
:param audio: Audio file to send.
:type audio: :obj:`typing.Union[base.InputFile, base.String]`
:param caption: Audio caption, 0-200 characters
:param caption: Audio caption, 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -738,7 +743,7 @@ class Message(base.TelegramObject):
A thumbnails width and height should not exceed 320.
:type thumb: :obj:`typing.Union[base.InputFile, base.String, None]`
:param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters
:param caption: Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -817,7 +822,7 @@ class Message(base.TelegramObject):
:param voice: Audio file to send.
:type voice: :obj:`typing.Union[base.InputFile, base.String]`
:param caption: Voice message caption, 0-200 characters
:param caption: Voice message caption, 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -1602,7 +1607,7 @@ class Message(base.TelegramObject):
:param audio: Audio file to send.
:type audio: :obj:`typing.Union[base.InputFile, base.String]`
:param caption: Audio caption, 0-200 characters
:param caption: Audio caption, 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -1879,7 +1884,7 @@ class Message(base.TelegramObject):
A thumbnails width and height should not exceed 320.
:type thumb: :obj:`typing.Union[base.InputFile, base.String, None]`
:param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters
:param caption: Video caption (may also be used when resending videos by file_id), 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -1958,7 +1963,7 @@ class Message(base.TelegramObject):
:param voice: Audio file to send.
:type voice: :obj:`typing.Union[base.InputFile, base.String]`
:param caption: Voice message caption, 0-200 characters
:param caption: Voice message caption, 0-1024 characters after entities parsing
:type caption: :obj:`typing.Optional[base.String]`
:param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic,
@ -3032,6 +3037,7 @@ class ContentType(helper.Helper):
GROUP_CHAT_CREATED = helper.Item() # group_chat_created
PASSPORT_DATA = helper.Item() # passport_data
PROXIMITY_ALERT_TRIGGERED = helper.Item() # proximity_alert_triggered
VOICE_CHAT_SCHEDULED = helper.Item() # voice_chat_scheduled
VOICE_CHAT_STARTED = helper.Item() # voice_chat_started
VOICE_CHAT_ENDED = helper.Item() # voice_chat_ended
VOICE_CHAT_PARTICIPANTS_INVITED = helper.Item() # voice_chat_participants_invited

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from functools import lru_cache
from . import base
from . import fields
from .callback_query import CallbackQuery
@ -72,3 +74,9 @@ class AllowedUpdates(helper.Helper):
"Use `CHOSEN_INLINE_RESULT`",
new_value_getter=lambda cls: cls.CHOSEN_INLINE_RESULT,
)
@classmethod
@lru_cache(1)
def default(cls):
excluded = cls.CHAT_MEMBER + cls.MY_CHAT_MEMBER
return list(filter(lambda item: item not in excluded, cls.all()))

View file

@ -0,0 +1,15 @@
from datetime import datetime
from . import base
from . import fields
from .user import User
class VoiceChatScheduled(base.TelegramObject):
"""
This object represents a service message about a voice chat scheduled in the chat.
https://core.telegram.org/bots/api#voicechatscheduled
"""
start_date: datetime = fields.DateTimeField()

View file

@ -1,10 +1,10 @@
"""
Deep linking
Telegram bots have a deep linking mechanism, that allows for passing additional
parameters to the bot on startup. It could be a command that launches the bot or
an auth token to connect the user's Telegram account to their account on some
external service.
Telegram bots have a deep linking mechanism, that allows for passing
additional parameters to the bot on startup. It could be a command that
launches the bot or an auth token to connect the user's Telegram
account to their account on some external service.
You can read detailed description in the source:
https://core.telegram.org/bots#deep-linking
@ -16,86 +16,123 @@ Basic link example:
.. code-block:: python
from aiogram.utils.deep_linking import get_start_link
link = await get_start_link('foo') # result: 'https://t.me/MyBot?start=foo'
link = await get_start_link('foo')
# result: 'https://t.me/MyBot?start=foo'
Encoded link example:
.. code-block:: python
from aiogram.utils.deep_linking import get_start_link, decode_payload
link = await get_start_link('foo', encode=True) # result: 'https://t.me/MyBot?start=Zm9v'
# and decode it back:
payload = decode_payload('Zm9v') # result: 'foo'
from aiogram.utils.deep_linking import get_start_link
link = await get_start_link('foo', encode=True)
# result: 'https://t.me/MyBot?start=Zm9v'
Decode it back example:
.. code-block:: python
from aiogram.utils.deep_linking import decode_payload
from aiogram.types import Message
@dp.message_handler(commands=["start"])
async def handler(message: Message):
args = message.get_args()
payload = decode_payload(args)
await message.answer(f"Your payload: {payload}")
"""
import re
from base64 import urlsafe_b64decode, urlsafe_b64encode
from ..bot import Bot
BAD_PATTERN = re.compile(r"[^_A-z0-9-]")
async def get_start_link(payload: str, encode=False) -> str:
"""
Use this method to handy get 'start' deep link with your payload.
If you need to encode payload or pass special characters - set encode as True
Get 'start' deep link with your payload.
If you need to encode payload or pass special characters -
set encode as True
:param payload: args passed with /start
:param encode: encode payload with base64url
:return: link
"""
return await _create_link('start', payload, encode)
return await _create_link(
link_type="start",
payload=payload,
encode=encode,
)
async def get_startgroup_link(payload: str, encode=False) -> str:
"""
Use this method to handy get 'startgroup' deep link with your payload.
If you need to encode payload or pass special characters - set encode as True
Get 'startgroup' deep link with your payload.
If you need to encode payload or pass special characters -
set encode as True
:param payload: args passed with /start
:param encode: encode payload with base64url
:return: link
"""
return await _create_link('startgroup', payload, encode)
return await _create_link(
link_type="startgroup",
payload=payload,
encode=encode,
)
async def _create_link(link_type, payload: str, encode=False):
"""
Create deep link.
:param link_type: `start` or `startgroup`
:param payload: any string-convertible data
:param encode: pass True to encode the payload
:return: deeplink
"""
bot = await _get_bot_user()
payload = filter_payload(payload)
if encode:
payload = encode_payload(payload)
return f'https://t.me/{bot.username}?{link_type}={payload}'
def encode_payload(payload: str) -> str:
""" Encode payload with URL-safe base64url. """
from base64 import urlsafe_b64encode
result: bytes = urlsafe_b64encode(payload.encode())
return result.decode()
def decode_payload(payload: str) -> str:
""" Decode payload with URL-safe base64url. """
from base64 import urlsafe_b64decode
result: bytes = urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4))
return result.decode()
def filter_payload(payload: str) -> str:
""" Convert payload to text and search for not allowed symbols. """
import re
# convert to string
if not isinstance(payload, str):
payload = str(payload)
# search for not allowed characters
if re.search(r'[^_A-z0-9-]', payload):
message = ('Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. '
'We recommend to encode parameters with binary and other '
'types of content.')
if encode:
payload = encode_payload(payload)
if re.search(BAD_PATTERN, payload):
message = (
"Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. "
"Pass `encode=True` or encode payload manually."
)
raise ValueError(message)
return payload
if len(payload) > 64:
message = "Payload must be up to 64 characters long."
raise ValueError(message)
return f"https://t.me/{bot.username}?{link_type}={payload}"
def encode_payload(payload: str) -> str:
"""Encode payload with URL-safe base64url."""
payload = str(payload)
bytes_payload: bytes = urlsafe_b64encode(payload.encode())
str_payload = bytes_payload.decode()
return str_payload.replace("=", "")
def decode_payload(payload: str) -> str:
"""Decode payload with URL-safe base64url."""
payload += "=" * (4 - len(payload) % 4)
result: bytes = urlsafe_b64decode(payload)
return result.decode()
async def _get_bot_user():
""" Get current user of bot. """
from ..bot import Bot
"""Get current user of bot."""
bot = Bot.get_current()
return await bot.me

View file

@ -2,7 +2,7 @@ import asyncio
import datetime
import functools
import secrets
from typing import Callable, Union, Optional, Any
from typing import Callable, Union, Optional, Any, List
from warnings import warn
from aiohttp import web
@ -23,7 +23,8 @@ def _setup_callbacks(executor: '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=20, relax=0.1, fast=True):
on_startup=None, on_shutdown=None, timeout=20, relax=0.1, fast=True,
allowed_updates: Optional[List[str]] = None):
"""
Start bot in long-polling mode
@ -34,11 +35,20 @@ def start_polling(dispatcher, *, loop=None, skip_updates=False, reset_webhook=Tr
:param on_startup:
:param on_shutdown:
:param timeout:
:param relax:
:param fast:
:param allowed_updates:
"""
executor = Executor(dispatcher, skip_updates=skip_updates, loop=loop)
_setup_callbacks(executor, on_startup, on_shutdown)
executor.start_polling(reset_webhook=reset_webhook, timeout=timeout, relax=relax, fast=fast)
executor.start_polling(
reset_webhook=reset_webhook,
timeout=timeout,
relax=relax,
fast=fast,
allowed_updates=allowed_updates
)
def set_webhook(dispatcher: Dispatcher, webhook_path: str, *, loop: Optional[asyncio.AbstractEventLoop] = None,
@ -295,7 +305,8 @@ 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=20, relax=0.1, fast=True):
def start_polling(self, reset_webhook=None, timeout=20, relax=0.1, fast=True,
allowed_updates: Optional[List[str]] = None):
"""
Start bot in long-polling mode
@ -308,7 +319,7 @@ class Executor:
try:
loop.run_until_complete(self._startup_polling())
loop.create_task(self.dispatcher.start_polling(reset_webhook=reset_webhook, timeout=timeout,
relax=relax, fast=fast))
relax=relax, fast=fast, allowed_updates=allowed_updates))
loop.run_forever()
except (KeyboardInterrupt, SystemExit):
# loop.stop()

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-5.1-blue.svg?style=flat-square&logo=telegram
.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram
:target: https://core.telegram.org/bots/api
:alt: Telegram Bot API

View file

@ -18,7 +18,7 @@ bot = Bot(token=API_TOKEN)
dp = Dispatcher(bot)
# if the text from user in the list
# if the text is equal to any string in the list
@dp.message_handler(text=['text1', 'text2'])
async def text_in_handler(message: types.Message):
await message.answer("The message text equals to one of in the list!")

View file

@ -1,7 +1,11 @@
import pytest
from aiogram.utils.deep_linking import decode_payload, encode_payload, filter_payload
from aiogram.utils.deep_linking import get_start_link, get_startgroup_link
from aiogram.utils.deep_linking import (
decode_payload,
encode_payload,
get_start_link,
get_startgroup_link,
)
from tests.types import dataset
# enable asyncio mode
@ -17,9 +21,11 @@ PAYLOADS = [
WRONG_PAYLOADS = [
'@BotFather',
"Some:special$characters#=",
'spaces spaces spaces',
1234567890123456789.0,
]
USERNAME = dataset.USER["username"]
@pytest.fixture(params=PAYLOADS, name='payload')
@ -47,7 +53,7 @@ def get_bot_user_fixture(monkeypatch):
class TestDeepLinking:
async def test_get_start_link(self, payload):
link = await get_start_link(payload)
assert link == f'https://t.me/{dataset.USER["username"]}?start={payload}'
assert link == f'https://t.me/{USERNAME}?start={payload}'
async def test_wrong_symbols(self, wrong_payload):
with pytest.raises(ValueError):
@ -55,20 +61,29 @@ class TestDeepLinking:
async def test_get_startgroup_link(self, payload):
link = await get_startgroup_link(payload)
assert link == f'https://t.me/{dataset.USER["username"]}?startgroup={payload}'
assert link == f'https://t.me/{USERNAME}?startgroup={payload}'
async def test_filter_encode_and_decode(self, payload):
_payload = filter_payload(payload)
encoded = encode_payload(_payload)
encoded = encode_payload(payload)
decoded = decode_payload(encoded)
assert decoded == str(payload)
async def test_get_start_link_with_encoding(self, payload):
async def test_get_start_link_with_encoding(self, wrong_payload):
# define link
link = await get_start_link(payload, encode=True)
link = await get_start_link(wrong_payload, encode=True)
# define reference link
payload = filter_payload(payload)
encoded_payload = encode_payload(payload)
encoded_payload = encode_payload(wrong_payload)
assert link == f'https://t.me/{dataset.USER["username"]}?start={encoded_payload}'
assert link == f'https://t.me/{USERNAME}?start={encoded_payload}'
async def test_64_len_payload(self):
payload = "p" * 64
link = await get_start_link(payload)
assert link
async def test_too_long_payload(self):
payload = "p" * 65
print(payload, len(payload))
with pytest.raises(ValueError):
await get_start_link(payload)