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 .bot import Bot
from .utils.versions import Version, Stage 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 __version__ = VERSION.version

View file

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

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import functools
import logging import logging
import typing import typing
@ -8,6 +9,7 @@ from .storage import DisabledStorage, BaseStorage, FSMContext
from .webhook import BaseResponse from .webhook import BaseResponse
from ..bot import Bot from ..bot import Bot
from ..types.message import ContentType from ..types.message import ContentType
from ..utils.exceptions import TelegramAPIError, NetworkError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -77,7 +79,7 @@ class Dispatcher:
""" """
tasks = [] tasks = []
for update in updates: 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) return await asyncio.gather(*tasks)
async def process_update(self, update): async def process_update(self, update):
@ -125,10 +127,9 @@ class Dispatcher:
while self._pooling: while self._pooling:
try: try:
updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout)
except Exception as e: except NetworkError:
log.exception('Cause exception while getting updates') log.exception('Cause exception while getting updates.')
if relax: await asyncio.sleep(15)
await asyncio.sleep(relax)
continue continue
if updates: if updates:
@ -137,7 +138,8 @@ class Dispatcher:
self.loop.create_task(self._process_pooling_updates(updates)) self.loop.create_task(self._process_pooling_updates(updates))
await asyncio.sleep(relax) if relax:
await asyncio.sleep(relax)
log.warning('Pooling is stopped.') log.warning('Pooling is stopped.')
@ -157,7 +159,7 @@ class Dispatcher:
if need_to_call: if need_to_call:
try: try:
asyncio.gather(*need_to_call) asyncio.gather(*need_to_call)
except Exception as e: except TelegramAPIError:
log.exception('Cause exception while processing updates.') log.exception('Cause exception while processing updates.')
def stop_pooling(self): def stop_pooling(self):
@ -711,3 +713,30 @@ class Dispatcher:
chat: typing.Union[str, int, None] = None, chat: typing.Union[str, int, None] = None,
user: typing.Union[str, int, None] = None) -> FSMContext: user: typing.Union[str, int, None] = None) -> FSMContext:
return FSMContext(storage=self.storage, chat=chat, user=user) 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): 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_), if inspect.isawaitable(filter_) or inspect.iscoroutinefunction(filter_):
inspect.iscoroutine(filter_),
inspect.isawaitable(filter_),
inspect.isasyncgenfunction(filter_),
inspect.iscoroutinefunction(filter_))):
return await filter_(*args, **kwargs) return await filter_(*args, **kwargs)
elif callable(filter_):
return filter_(*args, **kwargs)
else: else:
return True return filter_(*args, **kwargs)
async def check_filters(filters, args, kwargs): async def check_filters(filters, args, kwargs):

View file

@ -1,4 +1,7 @@
import asyncio
import asyncio.tasks
import datetime import datetime
import functools
import typing import typing
from typing import Union, Dict, Optional from typing import Union, Dict, Optional
@ -8,11 +11,15 @@ from .. import types
from ..bot import api from ..bot import api
from ..bot.base import Integer, String, Boolean, Float from ..bot.base import Integer, String, Boolean, Float
from ..utils import json from ..utils import json
from ..utils.deprecated import warn_deprecated as warn
from ..utils.exceptions import TimeoutWarning
from ..utils.payload import prepare_arg from ..utils.payload import prepare_arg
DEFAULT_WEB_PATH = '/webhook' DEFAULT_WEB_PATH = '/webhook'
BOT_DISPATCHER_KEY = 'BOT_DISPATCHER' BOT_DISPATCHER_KEY = 'BOT_DISPATCHER'
RESPONSE_TIMEOUT = 55
class WebhookRequestHandler(web.View): class WebhookRequestHandler(web.View):
""" """
@ -66,12 +73,83 @@ class WebhookRequestHandler(web.View):
""" """
dispatcher = self.get_dispatcher() dispatcher = self.get_dispatcher()
update = await self.parse_update(dispatcher.bot) 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: for result in results:
if isinstance(result, BaseResponse): if isinstance(result, BaseResponse):
return result.get_web_response() return result
return web.Response(text='ok')
def configure_app(dispatcher, app: web.Application, path=DEFAULT_WEB_PATH): 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()) 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. You can send message with webhook by using this instance of this object.
All arguments is equal with :method:`Bot.send_message` method. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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 .base import Deserializable
from .chat_photo import ChatPhoto from .chat_photo import ChatPhoto
from ..utils.helper import Helper, HelperMode, Item 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, 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.id: int = id
self.type: str = type self.type: str = type
self.title: str = title self.title: str = title
@ -22,9 +25,12 @@ class Chat(Deserializable):
self.photo: ChatPhoto = photo self.photo: ChatPhoto = photo
self.description: str = description self.description: str = description
self.invite_link: str = invite_link self.invite_link: str = invite_link
self.pinned_message: Message = pinned_message
@classmethod @classmethod
def de_json(cls, raw_data) -> 'Chat': def de_json(cls, raw_data) -> 'Chat':
from .message import Message
id: int = raw_data.get('id') id: int = raw_data.get('id')
type: str = raw_data.get('type') type: str = raw_data.get('type')
title: str = raw_data.get('title') title: str = raw_data.get('title')
@ -35,9 +41,10 @@ class Chat(Deserializable):
photo = raw_data.get('photo') photo = raw_data.get('photo')
description = raw_data.get('description') description = raw_data.get('description')
invite_link = raw_data.get('invite_link') 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, return Chat(id, type, title, username, first_name, last_name, all_members_are_administrators, photo,
description, invite_link) description, invite_link, pinned_message)
@property @property
def full_name(self): def full_name(self):
@ -59,6 +66,20 @@ class Chat(Deserializable):
return self.full_name return self.full_name
return None 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): async def set_photo(self, photo):
return await self.bot.set_chat_photo(self.id, 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, 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, forward_signature, forward_date, reply_to_message, edit_date, author_signature, text, entities, audio,
video, voice, video_note, new_chat_members, caption, contact, location, venue, left_chat_member, document, game, photo, sticker, video, voice, video_note, new_chat_members, caption, contact, location,
new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created, supergroup_chat_created, venue, left_chat_member, new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created,
channel_chat_created, migrate_to_chat_id, migrate_from_chat_id, pinned_message, invoice, supergroup_chat_created, channel_chat_created, migrate_to_chat_id, migrate_from_chat_id,
successful_payment, content_type): pinned_message, invoice, successful_payment, content_type):
self.message_id: int = message_id self.message_id: int = message_id
self.from_user: User = from_user self.from_user: User = from_user
self.date: datetime.datetime = date self.date: datetime.datetime = date
@ -41,9 +41,11 @@ class Message(Deserializable):
self.forward_from: User = forward_from self.forward_from: User = forward_from
self.forward_from_chat: Chat = forward_from_chat self.forward_from_chat: Chat = forward_from_chat
self.forward_from_message_id: int = forward_from_message_id self.forward_from_message_id: int = forward_from_message_id
self.forward_signature: str = forward_signature
self.forward_date: datetime.datetime = forward_date self.forward_date: datetime.datetime = forward_date
self.reply_to_message: Message = reply_to_message self.reply_to_message: Message = reply_to_message
self.edit_date: datetime.datetime = edit_date self.edit_date: datetime.datetime = edit_date
self.author_signature: str = author_signature
self.text: str = text self.text: str = text
self.entities = entities self.entities = entities
self.audio = audio self.audio = audio
@ -83,9 +85,11 @@ class Message(Deserializable):
forward_from = User.deserialize(raw_data.get('forward_from', {})) forward_from = User.deserialize(raw_data.get('forward_from', {}))
forward_from_chat = Chat.deserialize(raw_data.get('forward_from_chat', {})) forward_from_chat = Chat.deserialize(raw_data.get('forward_from_chat', {}))
forward_from_message_id = raw_data.get('forward_from_message_id') 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)) forward_date = cls._parse_date(raw_data.get('forward_date', 0))
reply_to_message = Message.deserialize(raw_data.get('reply_to_message', {})) reply_to_message = Message.deserialize(raw_data.get('reply_to_message', {}))
edit_date = cls._parse_date(raw_data.get('edit_date', 0)) edit_date = cls._parse_date(raw_data.get('edit_date', 0))
author_signature = raw_data.get('author_signature')
text = raw_data.get('text') text = raw_data.get('text')
entities = MessageEntity.deserialize(raw_data.get('entities')) entities = MessageEntity.deserialize(raw_data.get('entities'))
audio = Audio.deserialize(raw_data.get('audio')) audio = Audio.deserialize(raw_data.get('audio'))
@ -142,11 +146,11 @@ class Message(Deserializable):
content_type = ContentType.UNKNOWN[0] content_type = ContentType.UNKNOWN[0]
return Message(message_id, from_user, date, chat, forward_from, forward_from_chat, forward_from_message_id, 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, forward_signature, forward_date, reply_to_message, edit_date, author_signature, text, entities,
video, voice, video_note, new_chat_members, caption, contact, location, venue, left_chat_member, audio, document, game, photo, sticker, video, voice, video_note, new_chat_members, caption,
new_chat_title, new_chat_photo, delete_chat_photo, group_chat_created, supergroup_chat_created, contact, location, venue, left_chat_member, new_chat_title, new_chat_photo, delete_chat_photo,
channel_chat_created, migrate_to_chat_id, migrate_from_chat_id, pinned_message, invoice, group_chat_created, supergroup_chat_created, channel_chat_created, migrate_to_chat_id,
successful_payment, content_type) migrate_from_chat_id, pinned_message, invoice, successful_payment, content_type)
def is_command(self): def is_command(self):
""" """

View file

@ -1,3 +1,5 @@
from ..utils.markdown import link, hlink
try: try:
import babel import babel
except ImportError: except ImportError:
@ -13,8 +15,9 @@ class User(Deserializable):
https://core.telegram.org/bots/api#user 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.id: int = id
self.is_bot: bool = is_bot
self.first_name: str = first_name self.first_name: str = first_name
self.last_name: str = last_name self.last_name: str = last_name
self.username: str = username self.username: str = username
@ -23,12 +26,13 @@ class User(Deserializable):
@classmethod @classmethod
def de_json(cls, raw_data: str or dict) -> 'User': def de_json(cls, raw_data: str or dict) -> 'User':
id = raw_data.get('id') id = raw_data.get('id')
is_bot = raw_data.get('is_bot')
first_name = raw_data.get('first_name') first_name = raw_data.get('first_name')
last_name = raw_data.get('last_name') last_name = raw_data.get('last_name')
username = raw_data.get('username') username = raw_data.get('username')
language_code = raw_data.get('language_code') 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 @property
def full_name(self): def full_name(self):
@ -69,5 +73,16 @@ class User(Deserializable):
setattr(self, '_locale', babel.core.Locale.parse(self.language_code, sep='-')) setattr(self, '_locale', babel.core.Locale.parse(self.language_code, sep='-'))
return getattr(self, '_locale') 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): async def get_user_profile_photos(self, offset=None, limit=None):
return await self.bot.get_user_profile_photos(self.id, offset, limit) 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): def _clean_message(text):
for prefix in _PREFIXES: for prefix in _PREFIXES:
if text.startswith(prefix): if text.startswith(prefix):
text = text[len(prefix):] text = text[len(prefix):]
return text return (text[0].upper() + text[1:]).strip()
class TelegramAPIError(Exception): class TelegramAPIError(Exception):
@ -13,6 +13,14 @@ class TelegramAPIError(Exception):
super(TelegramAPIError, self).__init__(_clean_message(message)) super(TelegramAPIError, self).__init__(_clean_message(message))
class AIOGramWarning(Warning):
pass
class TimeoutWarning(AIOGramWarning):
pass
class ValidationError(TelegramAPIError): class ValidationError(TelegramAPIError):
pass pass
@ -21,6 +29,10 @@ class BadRequest(TelegramAPIError):
pass pass
class ConflictError(TelegramAPIError):
pass
class Unauthorized(TelegramAPIError): class Unauthorized(TelegramAPIError):
pass pass