Merge branch 'dev'

# Conflicts:
#	aiogram/__init__.py
This commit is contained in:
Alex Root Junior 2017-08-23 23:29:26 +03:00
commit a45dbe1e94
9 changed files with 222 additions and 50 deletions

View file

@ -1,6 +1,6 @@
from .bot import Bot
from .utils.versions import Version, Stage
VERSION = Version(0, 4, stage=Stage.FINAL, build=0)
VERSION = Version(0, 4, 1, stage=Stage.DEV, build=0)
__version__ = VERSION.version

View file

@ -7,7 +7,7 @@ import aiohttp
from ..utils import json
from ..utils.exceptions import ValidationError, TelegramAPIError, BadRequest, Unauthorized, NetworkError, RetryAfter, \
MigrateToChat
MigrateToChat, ConflictError
from ..utils.helper import Helper, HelperMode, Item
# Main aiogram logger
@ -66,6 +66,8 @@ async def _check_result(method_name, response):
raise MigrateToChat(result_json['migrate_to_chat_id'])
elif response.status == HTTPStatus.BAD_REQUEST:
raise BadRequest(description)
elif response.status == HTTPStatus.CONFLICT:
raise ConflictError(description)
elif response.status in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]:
raise Unauthorized(description)
elif response.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:

View file

@ -1,4 +1,5 @@
import asyncio
import functools
import logging
import typing
@ -8,6 +9,7 @@ from .storage import DisabledStorage, BaseStorage, FSMContext
from .webhook import BaseResponse
from ..bot import Bot
from ..types.message import ContentType
from ..utils.exceptions import TelegramAPIError, NetworkError
log = logging.getLogger(__name__)
@ -77,7 +79,7 @@ class Dispatcher:
"""
tasks = []
for update in updates:
tasks.append(self.loop.create_task(self.updates_handler.notify(update)))
tasks.append(self.updates_handler.notify(update))
return await asyncio.gather(*tasks)
async def process_update(self, update):
@ -125,10 +127,9 @@ class Dispatcher:
while self._pooling:
try:
updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout)
except Exception as e:
log.exception('Cause exception while getting updates')
if relax:
await asyncio.sleep(relax)
except NetworkError:
log.exception('Cause exception while getting updates.')
await asyncio.sleep(15)
continue
if updates:
@ -137,7 +138,8 @@ class Dispatcher:
self.loop.create_task(self._process_pooling_updates(updates))
await asyncio.sleep(relax)
if relax:
await asyncio.sleep(relax)
log.warning('Pooling is stopped.')
@ -157,7 +159,7 @@ class Dispatcher:
if need_to_call:
try:
asyncio.gather(*need_to_call)
except Exception as e:
except TelegramAPIError:
log.exception('Cause exception while processing updates.')
def stop_pooling(self):
@ -711,3 +713,30 @@ class Dispatcher:
chat: typing.Union[str, int, None] = None,
user: typing.Union[str, int, None] = None) -> FSMContext:
return FSMContext(storage=self.storage, chat=chat, user=user)
def async_task(self, func):
"""
Execute handler as task and return None.
Use that decorator for slow handlers (with timeouts)
.. code-block:: python3
@dp.message_handler(commands=['command'])
@dp.async_task
async def cmd_with_timeout(message: types.Message):
await asyncio.sleep(120)
return SendMessage(message.chat.id, 'KABOOM').reply(message)
:param func:
:return:
"""
def process_response(task):
response = task.result()
self.loop.create_task(response.execute_response(self.bot))
@functools.wraps(func)
async def wrapper(*args, **kwargs):
task = self.loop.create_task(func(*args, **kwargs))
task.add_done_callback(process_response)
return wrapper

View file

@ -5,18 +5,13 @@ from ..utils.helper import Helper, HelperMode, Item
async def check_filter(filter_, args, kwargs):
# TODO: Refactor that shit.
if not callable(filter_):
raise TypeError('Filter must be callable and/or awaitable!')
if any((inspect.isasyncgen(filter_),
inspect.iscoroutine(filter_),
inspect.isawaitable(filter_),
inspect.isasyncgenfunction(filter_),
inspect.iscoroutinefunction(filter_))):
if inspect.isawaitable(filter_) or inspect.iscoroutinefunction(filter_):
return await filter_(*args, **kwargs)
elif callable(filter_):
return filter_(*args, **kwargs)
else:
return True
return filter_(*args, **kwargs)
async def check_filters(filters, args, kwargs):

View file

@ -1,4 +1,7 @@
import asyncio
import asyncio.tasks
import datetime
import functools
import typing
from typing import Union, Dict, Optional
@ -8,11 +11,15 @@ from .. import types
from ..bot import api
from ..bot.base import Integer, String, Boolean, Float
from ..utils import json
from ..utils.deprecated import warn_deprecated as warn
from ..utils.exceptions import TimeoutWarning
from ..utils.payload import prepare_arg
DEFAULT_WEB_PATH = '/webhook'
BOT_DISPATCHER_KEY = 'BOT_DISPATCHER'
RESPONSE_TIMEOUT = 55
class WebhookRequestHandler(web.View):
"""
@ -66,12 +73,83 @@ class WebhookRequestHandler(web.View):
"""
dispatcher = self.get_dispatcher()
update = await self.parse_update(dispatcher.bot)
results = await dispatcher.process_update(update)
results = await self.process_update(update)
response = self.get_response(results)
if response:
return response.get_web_response()
return web.Response(text='ok')
async def process_update(self, update):
"""
Need respond in less than 60 seconds in to webhook.
So... If you respond greater than 55 seconds webhook automatically respond 'ok'
and execute callback response via simple HTTP request.
:param update:
:return:
"""
dispatcher = self.get_dispatcher()
loop = dispatcher.loop
# Analog of `asyncio.wait_for` but without cancelling task
waiter = loop.create_future()
timeout_handle = loop.call_later(RESPONSE_TIMEOUT, asyncio.tasks._release_waiter, waiter)
cb = functools.partial(asyncio.tasks._release_waiter, waiter)
fut = asyncio.ensure_future(dispatcher.process_update(update), loop=loop)
fut.add_done_callback(cb)
try:
try:
await waiter
except asyncio.futures.CancelledError:
fut.remove_done_callback(cb)
fut.cancel()
raise
if fut.done():
return fut.result()
else:
fut.remove_done_callback(cb)
fut.add_done_callback(self.respond_via_request)
finally:
timeout_handle.cancel()
def respond_via_request(self, task):
"""
Handle response after 55 second.
:param task:
:return:
"""
warn(f"Detected slow response into webhook. "
f"(Greater than {RESPONSE_TIMEOUT} seconds)\n"
f"Recommended to use 'async_task' decorator from Dispatcher for handler with long timeouts.",
TimeoutWarning)
dispatcher = self.get_dispatcher()
loop = dispatcher.loop
results = task.result()
response = self.get_response(results)
if response is not None:
asyncio.ensure_future(response.execute_response(self.get_dispatcher().bot), loop=loop)
def get_response(self, results):
"""
Get response object from results.
:param results: list
:return:
"""
if results is None:
return None
for result in results:
if isinstance(result, BaseResponse):
return result.get_web_response()
return web.Response(text='ok')
return result
def configure_app(dispatcher, app: web.Application, path=DEFAULT_WEB_PATH):
@ -156,7 +234,23 @@ class BaseResponse:
return await bot.request(self.method, self.cleanup())
class SendMessage(BaseResponse):
class ReplyToMixin:
"""
Mixin for responses where from which can reply to messages.
"""
def reply(self, message: typing.Union[int, types.Message]):
"""
Reply to message
:param message: :obj:`int` or :obj:`types.Message`
:return: self
"""
setattr(self, 'reply_to_message_id', message.message_id if isinstance(message, types.Message) else message)
return self
class SendMessage(BaseResponse, ReplyToMixin):
"""
You can send message with webhook by using this instance of this object.
All arguments is equal with :method:`Bot.send_message` method.
@ -245,7 +339,7 @@ class ForwardMessage(BaseResponse):
}
class SendPhoto(BaseResponse):
class SendPhoto(BaseResponse, ReplyToMixin):
"""
Use that response type for send photo on to webhook.
"""
@ -294,7 +388,7 @@ class SendPhoto(BaseResponse):
}
class SendAudio(BaseResponse):
class SendAudio(BaseResponse, ReplyToMixin):
"""
Use that response type for send audio on to webhook.
"""
@ -356,7 +450,7 @@ class SendAudio(BaseResponse):
}
class SendDocument(BaseResponse):
class SendDocument(BaseResponse, ReplyToMixin):
"""
Use that response type for send document on to webhook.
"""
@ -406,7 +500,7 @@ class SendDocument(BaseResponse):
}
class SendVideo(BaseResponse):
class SendVideo(BaseResponse, ReplyToMixin):
"""
Use that response type for send video on to webhook.
"""
@ -469,7 +563,7 @@ class SendVideo(BaseResponse):
}
class SendVoice(BaseResponse):
class SendVoice(BaseResponse, ReplyToMixin):
"""
Use that response type for send voice on to webhook.
"""
@ -523,7 +617,7 @@ class SendVoice(BaseResponse):
}
class SendVideoNote(BaseResponse):
class SendVideoNote(BaseResponse, ReplyToMixin):
"""
Use that response type for send video note on to webhook.
"""
@ -576,7 +670,7 @@ class SendVideoNote(BaseResponse):
}
class SendLocation(BaseResponse):
class SendLocation(BaseResponse, ReplyToMixin):
"""
Use that response type for send location on to webhook.
"""
@ -621,7 +715,7 @@ class SendLocation(BaseResponse):
}
class SendVenue(BaseResponse):
class SendVenue(BaseResponse, ReplyToMixin):
"""
Use that response type for send venue on to webhook.
"""
@ -680,7 +774,7 @@ class SendVenue(BaseResponse):
}
class SendContact(BaseResponse):
class SendContact(BaseResponse, ReplyToMixin):
"""
Use that response type for send contact on to webhook.
"""
@ -1278,7 +1372,7 @@ class DeleteMessage(BaseResponse):
}
class SendSticker(BaseResponse):
class SendSticker(BaseResponse, ReplyToMixin):
"""
Use that response type for send sticker on to webhook.
"""
@ -1524,7 +1618,7 @@ class AnswerInlineQuery(BaseResponse):
}
class SendInvoice(BaseResponse):
class SendInvoice(BaseResponse, ReplyToMixin):
"""
Use that response type for send invoice on to webhook.
"""
@ -1705,7 +1799,7 @@ class AnswerPreCheckoutQuery(BaseResponse):
}
class SendGame(BaseResponse):
class SendGame(BaseResponse, ReplyToMixin):
"""
Use that response type for send game on to webhook.
"""

View file

@ -1,3 +1,4 @@
from aiogram.utils.markdown import hlink, link
from .base import Deserializable
from .chat_photo import ChatPhoto
from ..utils.helper import Helper, HelperMode, Item
@ -11,7 +12,9 @@ class Chat(Deserializable):
"""
def __init__(self, id, type, title, username, first_name, last_name, all_members_are_administrators, photo,
description, invite_link):
description, invite_link, pinned_message):
from .message import Message
self.id: int = id
self.type: str = type
self.title: str = title
@ -22,9 +25,12 @@ class Chat(Deserializable):
self.photo: ChatPhoto = photo
self.description: str = description
self.invite_link: str = invite_link
self.pinned_message: Message = pinned_message
@classmethod
def de_json(cls, raw_data) -> 'Chat':
from .message import Message
id: int = raw_data.get('id')
type: str = raw_data.get('type')
title: str = raw_data.get('title')
@ -35,9 +41,10 @@ class Chat(Deserializable):
photo = raw_data.get('photo')
description = raw_data.get('description')
invite_link = raw_data.get('invite_link')
pinned_message: Message = Message.deserialize(raw_data.get('pinned_message'))
return Chat(id, type, title, username, first_name, last_name, all_members_are_administrators, photo,
description, invite_link)
description, invite_link, pinned_message)
@property
def full_name(self):
@ -59,6 +66,20 @@ class Chat(Deserializable):
return self.full_name
return None
@property
def user_url(self):
if self.type != ChatType.PRIVATE:
raise TypeError('This property available only in private chats.')
return f"tg://user?id={self.id}"
def get_mention(self, name=None, as_html=False):
if name is None:
name = self.mention
if as_html:
return hlink(name, self.user_url)
return link(name, self.user_url)
async def set_photo(self, photo):
return await self.bot.set_chat_photo(self.id, photo)

View file

@ -29,11 +29,11 @@ class Message(Deserializable):
"""
def __init__(self, message_id, from_user, date, chat, forward_from, forward_from_chat, forward_from_message_id,
forward_date, reply_to_message, edit_date, text, entities, audio, document, game, photo, sticker,
video, voice, video_note, new_chat_members, caption, contact, location, venue, left_chat_member,
new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created, supergroup_chat_created,
channel_chat_created, migrate_to_chat_id, migrate_from_chat_id, pinned_message, invoice,
successful_payment, content_type):
forward_signature, forward_date, reply_to_message, edit_date, author_signature, text, entities, audio,
document, game, photo, sticker, video, voice, video_note, new_chat_members, caption, contact, location,
venue, left_chat_member, new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created,
supergroup_chat_created, channel_chat_created, migrate_to_chat_id, migrate_from_chat_id,
pinned_message, invoice, successful_payment, content_type):
self.message_id: int = message_id
self.from_user: User = from_user
self.date: datetime.datetime = date
@ -41,9 +41,11 @@ class Message(Deserializable):
self.forward_from: User = forward_from
self.forward_from_chat: Chat = forward_from_chat
self.forward_from_message_id: int = forward_from_message_id
self.forward_signature: str = forward_signature
self.forward_date: datetime.datetime = forward_date
self.reply_to_message: Message = reply_to_message
self.edit_date: datetime.datetime = edit_date
self.author_signature: str = author_signature
self.text: str = text
self.entities = entities
self.audio = audio
@ -83,9 +85,11 @@ class Message(Deserializable):
forward_from = User.deserialize(raw_data.get('forward_from', {}))
forward_from_chat = Chat.deserialize(raw_data.get('forward_from_chat', {}))
forward_from_message_id = raw_data.get('forward_from_message_id')
forward_signature = raw_data.get('forward_signature')
forward_date = cls._parse_date(raw_data.get('forward_date', 0))
reply_to_message = Message.deserialize(raw_data.get('reply_to_message', {}))
edit_date = cls._parse_date(raw_data.get('edit_date', 0))
author_signature = raw_data.get('author_signature')
text = raw_data.get('text')
entities = MessageEntity.deserialize(raw_data.get('entities'))
audio = Audio.deserialize(raw_data.get('audio'))
@ -142,11 +146,11 @@ class Message(Deserializable):
content_type = ContentType.UNKNOWN[0]
return Message(message_id, from_user, date, chat, forward_from, forward_from_chat, forward_from_message_id,
forward_date, reply_to_message, edit_date, text, entities, audio, document, game, photo, sticker,
video, voice, video_note, new_chat_members, caption, contact, location, venue, left_chat_member,
new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created, supergroup_chat_created,
channel_chat_created, migrate_to_chat_id, migrate_from_chat_id, pinned_message, invoice,
successful_payment, content_type)
forward_signature, forward_date, reply_to_message, edit_date, author_signature, text, entities,
audio, document, game, photo, sticker, video, voice, video_note, new_chat_members, caption,
contact, location, venue, left_chat_member, new_chat_title, new_chat_photo, delete_chat_photo,
group_chat_created, supergroup_chat_created, channel_chat_created, migrate_to_chat_id,
migrate_from_chat_id, pinned_message, invoice, successful_payment, content_type)
def is_command(self):
"""

View file

@ -1,3 +1,5 @@
from ..utils.markdown import link, hlink
try:
import babel
except ImportError:
@ -13,8 +15,9 @@ class User(Deserializable):
https://core.telegram.org/bots/api#user
"""
def __init__(self, id, first_name, last_name, username, language_code):
def __init__(self, id, is_bot, first_name, last_name, username, language_code):
self.id: int = id
self.is_bot: bool = is_bot
self.first_name: str = first_name
self.last_name: str = last_name
self.username: str = username
@ -23,12 +26,13 @@ class User(Deserializable):
@classmethod
def de_json(cls, raw_data: str or dict) -> 'User':
id = raw_data.get('id')
is_bot = raw_data.get('is_bot')
first_name = raw_data.get('first_name')
last_name = raw_data.get('last_name')
username = raw_data.get('username')
language_code = raw_data.get('language_code')
return User(id, first_name, last_name, username, language_code)
return User(id, is_bot, first_name, last_name, username, language_code)
@property
def full_name(self):
@ -69,5 +73,16 @@ class User(Deserializable):
setattr(self, '_locale', babel.core.Locale.parse(self.language_code, sep='-'))
return getattr(self, '_locale')
@property
def url(self):
return f"tg://user?id={self.id}"
def get_mention(self, name=None, as_html=False):
if name is None:
name = self.mention
if as_html:
return hlink(name, self.url)
return link(name, self.url)
async def get_user_profile_photos(self, offset=None, limit=None):
return await self.bot.get_user_profile_photos(self.id, offset, limit)

View file

@ -1,11 +1,11 @@
_PREFIXES = ['Error: ', '[Error]: ', 'Bad Request: ']
_PREFIXES = ['Error: ', '[Error]: ', 'Bad Request: ', 'Conflict: ']
def _clean_message(text):
for prefix in _PREFIXES:
if text.startswith(prefix):
text = text[len(prefix):]
return text
return (text[0].upper() + text[1:]).strip()
class TelegramAPIError(Exception):
@ -13,6 +13,14 @@ class TelegramAPIError(Exception):
super(TelegramAPIError, self).__init__(_clean_message(message))
class AIOGramWarning(Warning):
pass
class TimeoutWarning(AIOGramWarning):
pass
class ValidationError(TelegramAPIError):
pass
@ -21,6 +29,10 @@ class BadRequest(TelegramAPIError):
pass
class ConflictError(TelegramAPIError):
pass
class Unauthorized(TelegramAPIError):
pass