diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 9cbadce1..be669f7c 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -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 diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 14cd0dba..9cd8fdf4 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -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: diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index d2603500..0230484d 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -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 diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py index d1968968..d62f5310 100644 --- a/aiogram/dispatcher/filters.py +++ b/aiogram/dispatcher/filters.py @@ -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): diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 53d2e42c..64f4b566 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -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. """ diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 1c2ac77f..aa6dd95e 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -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) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 6e6feb3b..de70af6b 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -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): """ diff --git a/aiogram/types/user.py b/aiogram/types/user.py index 50507d71..165abf73 100644 --- a/aiogram/types/user.py +++ b/aiogram/types/user.py @@ -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) diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index 4a14f914..edbf5bcd 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -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