diff --git a/aiogram/client/bot.py b/aiogram/client/bot.py index 19fe7838..ea84baa7 100644 --- a/aiogram/client/bot.py +++ b/aiogram/client/bot.py @@ -302,7 +302,7 @@ class Bot(ContextInstanceMixin["Bot"]): :param method: :return: """ - return await self.session.make_request(self, method, timeout=request_timeout) + return await self.session(self, method, timeout=request_timeout) def __hash__(self) -> int: """ diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py index 3373d800..dfb297ff 100644 --- a/aiogram/client/session/aiohttp.py +++ b/aiogram/client/session/aiohttp.py @@ -10,7 +10,6 @@ from typing import ( Optional, Tuple, Type, - TypeVar, Union, cast, ) @@ -19,12 +18,12 @@ from aiohttp import BasicAuth, ClientSession, FormData, TCPConnector from aiogram.methods import Request, TelegramMethod +from ...methods.base import TelegramType from .base import UNSET, BaseSession if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _ProxyBasic = Union[str, Tuple[str, BasicAuth]] _ProxyChain = Iterable[_ProxyBasic] _ProxyType = Union[_ProxyChain, _ProxyBasic] @@ -76,6 +75,8 @@ def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"] class AiohttpSession(BaseSession): def __init__(self, proxy: Optional[_ProxyType] = None): + super().__init__() + self._session: Optional[ClientSession] = None self._connector_type: Type[TCPConnector] = TCPConnector self._connector_init: Dict[str, Any] = {} @@ -86,7 +87,7 @@ class AiohttpSession(BaseSession): try: self._setup_proxy_connector(proxy) except ImportError as exc: # pragma: no cover - raise UserWarning( + raise RuntimeError( "In order to use aiohttp client for proxy requests, install " "https://pypi.org/project/aiohttp-socks/" ) from exc @@ -130,8 +131,8 @@ class AiohttpSession(BaseSession): return form async def make_request( - self, bot: Bot, call: TelegramMethod[T], timeout: Optional[int] = None - ) -> T: + self, bot: Bot, call: TelegramMethod[TelegramType], timeout: Optional[int] = None + ) -> TelegramType: session = await self.create_session() request = call.build_request(bot) @@ -141,11 +142,10 @@ class AiohttpSession(BaseSession): async with session.post( url, data=form, timeout=self.timeout if timeout is None else timeout ) as resp: - raw_result = await resp.json(loads=self.json_loads) + raw_result = await resp.text() - response = call.build_response(raw_result) - self.raise_for_status(response) - return cast(T, response.result) + response = self.check_response(method=call, status_code=resp.status, content=raw_result) + return cast(TelegramType, response.result) async def stream_content( self, url: str, timeout: int, chunk_size: int diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 7a8edd4a..2e752e72 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -3,32 +3,44 @@ from __future__ import annotations import abc import datetime import json +from functools import partial from types import TracebackType from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + Awaitable, Callable, ClassVar, + List, Optional, Type, - TypeVar, Union, + cast, ) -from aiogram.utils.exceptions import TelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError from aiogram.utils.helper import Default from ...methods import Response, TelegramMethod -from ...types import UNSET +from ...methods.base import TelegramType +from ...types import UNSET, TelegramObject +from ...utils.exceptions.special import MigrateToChat, RetryAfter +from ..errors_middleware import RequestErrorMiddleware from ..telegram import PRODUCTION, TelegramAPIServer if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _JsonLoads = Callable[..., Any] _JsonDumps = Callable[..., str] +NextRequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramObject]], Awaitable[Response[TelegramObject]] +] +RequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramType], NextRequestMiddlewareType], + Awaitable[Response[TelegramType]], +] class BaseSession(abc.ABC): @@ -43,16 +55,40 @@ class BaseSession(abc.ABC): timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout)) """Session scope request timeout""" - @classmethod - def raise_for_status(cls, response: Response[T]) -> None: + errors_middleware: ClassVar[RequestErrorMiddleware] = RequestErrorMiddleware() + + def __init__(self) -> None: + self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [ + self.errors_middleware, + ] + + def check_response( + self, method: TelegramMethod[TelegramType], status_code: int, content: str + ) -> Response[TelegramType]: """ Check response status - - :param response: Response instance """ + json_data = self.json_loads(content) + response = method.build_response(json_data) if response.ok: - return - raise TelegramAPIError(response.description) + return response + + description = cast(str, response.description) + if parameters := response.parameters: + if parameters.retry_after: + raise RetryAfter( + method=method, message=description, retry_after=parameters.retry_after + ) + if parameters.migrate_to_chat_id: + raise MigrateToChat( + method=method, + message=description, + migrate_to_chat_id=parameters.migrate_to_chat_id, + ) + raise TelegramAPIError( + method=method, + message=description, + ) @abc.abstractmethod async def close(self) -> None: # pragma: no cover @@ -63,8 +99,8 @@ class BaseSession(abc.ABC): @abc.abstractmethod async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: # pragma: no cover + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: # pragma: no cover """ Make request to Telegram Bot API @@ -111,6 +147,20 @@ class BaseSession(abc.ABC): return {k: self.clean_json(v) for k, v in value.items() if v is not None} return value + def middleware( + self, middleware: RequestMiddlewareType[TelegramObject] + ) -> RequestMiddlewareType[TelegramObject]: + self.middlewares.append(middleware) + return middleware + + async def __call__( + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: + middleware = partial(self.make_request, timeout=timeout) + for m in reversed(self.middlewares): + middleware = partial(m, make_request=middleware) # type: ignore + return await middleware(bot, method) + async def __aenter__(self) -> BaseSession: return self diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 2978d5bd..78ff5aaf 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -8,9 +8,9 @@ from typing import Any, AsyncGenerator, Dict, Optional, Union, cast from .. import loggers from ..client.bot import Bot -from ..methods import TelegramMethod +from ..methods import GetUpdates, TelegramMethod from ..types import TelegramObject, Update, User -from ..utils.exceptions import TelegramAPIError +from ..utils.exceptions.base import TelegramAPIError from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver from .fsm.context import FSMContext @@ -119,16 +119,22 @@ class Dispatcher(Router): return await self.feed_update(bot=bot, update=parsed_update, **kwargs) @classmethod - async def _listen_updates(cls, bot: Bot) -> AsyncGenerator[Update, None]: + async def _listen_updates( + cls, bot: Bot, polling_timeout: int = 30 + ) -> AsyncGenerator[Update, None]: """ Infinity updates reader """ - update_id: Optional[int] = None + get_updates = GetUpdates(timeout=polling_timeout) + kwargs = {} + if bot.session.timeout: + kwargs["request_timeout"] = int(bot.session.timeout + polling_timeout) while True: # TODO: Skip restarting telegram error - for update in await bot.get_updates(offset=update_id): + updates = await bot(get_updates, **kwargs) + for update in updates: yield update - update_id = update.update_id + 1 + get_updates.offset = update.update_id + 1 async def _listen_update(self, update: Update, **kwargs: Any) -> Any: """ @@ -249,7 +255,7 @@ class Dispatcher(Router): ) return True # because update was processed but unsuccessful - async def _polling(self, bot: Bot, **kwargs: Any) -> None: + async def _polling(self, bot: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: """ Internal polling process @@ -257,7 +263,7 @@ class Dispatcher(Router): :param kwargs: :return: """ - async for update in self._listen_updates(bot): + async for update in self._listen_updates(bot, polling_timeout=polling_timeout): await self._process_update(bot=bot, update=update, **kwargs) async def _feed_webhook_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: @@ -336,7 +342,7 @@ class Dispatcher(Router): return None - async def start_polling(self, *bots: Bot, **kwargs: Any) -> None: + async def start_polling(self, *bots: Bot, polling_timeout: int = 10, **kwargs: Any) -> None: """ Polling runner @@ -356,7 +362,9 @@ class Dispatcher(Router): loggers.dispatcher.info( "Run polling for bot @%s id=%d - %r", user.username, bot.id, user.full_name ) - coro_list.append(self._polling(bot=bot, **kwargs)) + coro_list.append( + self._polling(bot=bot, polling_timeout=polling_timeout, **kwargs) + ) await asyncio.gather(*coro_list) finally: for bot in bots: # Close sessions @@ -364,16 +372,19 @@ class Dispatcher(Router): loggers.dispatcher.info("Polling stopped") await self.emit_shutdown(**workflow_data) - def run_polling(self, *bots: Bot, **kwargs: Any) -> None: + def run_polling(self, *bots: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: """ Run many bots with polling - :param bots: - :param kwargs: + :param bots: Bot instances + :param polling_timeout: Poling timeout + :param kwargs: contextual data :return: """ try: - return asyncio.run(self.start_polling(*bots, **kwargs)) + return asyncio.run( + self.start_polling(*bots, **kwargs, polling_timeout=polling_timeout) + ) except (KeyboardInterrupt, SystemExit): # pragma: no cover # Allow to graceful shutdown pass diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index ab185043..50e2412d 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -150,7 +150,7 @@ class TelegramEventObserver: return UNHANDLED def __call__( - self, *args: FilterType, **bound_filters: BaseFilter + self, *args: FilterType, **bound_filters: Any ) -> Callable[[CallbackType], CallbackType]: """ Decorator for registering event handlers diff --git a/aiogram/dispatcher/fsm/middleware.py b/aiogram/dispatcher/fsm/middleware.py index d3d5d8c2..1e3ba91c 100644 --- a/aiogram/dispatcher/fsm/middleware.py +++ b/aiogram/dispatcher/fsm/middleware.py @@ -28,9 +28,9 @@ class FSMContextMiddleware(BaseMiddleware[Update]): data["fsm_storage"] = self.storage if context: data.update({"state": context, "raw_state": await context.get_state()}) - if self.isolate_events: - async with self.storage.lock(): - return await handler(event, data) + if self.isolate_events: + async with self.storage.lock(chat_id=context.chat_id, user_id=context.user_id): + return await handler(event, data) return await handler(event, data) def resolve_event_context(self, data: Dict[str, Any]) -> Optional[FSMContext]: diff --git a/aiogram/dispatcher/fsm/storage/base.py b/aiogram/dispatcher/fsm/storage/base.py index 36cebb31..f394cd61 100644 --- a/aiogram/dispatcher/fsm/storage/base.py +++ b/aiogram/dispatcher/fsm/storage/base.py @@ -10,7 +10,9 @@ StateType = Optional[Union[str, State]] class BaseStorage(ABC): @abstractmethod @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: # pragma: no cover + async def lock( + self, chat_id: int, user_id: int + ) -> AsyncGenerator[None, None]: # pragma: no cover yield None @abstractmethod diff --git a/aiogram/dispatcher/fsm/storage/memory.py b/aiogram/dispatcher/fsm/storage/memory.py index 46b6d60b..933e225c 100644 --- a/aiogram/dispatcher/fsm/storage/memory.py +++ b/aiogram/dispatcher/fsm/storage/memory.py @@ -12,6 +12,7 @@ from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType class MemoryStorageRecord: data: Dict[str, Any] = field(default_factory=dict) state: Optional[str] = None + lock: Lock = field(default_factory=Lock) class MemoryStorage(BaseStorage): @@ -19,11 +20,10 @@ class MemoryStorage(BaseStorage): self.storage: DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] = defaultdict( lambda: defaultdict(MemoryStorageRecord) ) - self._lock = Lock() @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: - async with self._lock: + async def lock(self, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: + async with self.storage[chat_id][user_id].lock: yield None async def set_state(self, chat_id: int, user_id: int, state: StateType = None) -> None: diff --git a/aiogram/methods/base.py b/aiogram/methods/base.py index ce73a35b..334beac0 100644 --- a/aiogram/methods/base.py +++ b/aiogram/methods/base.py @@ -12,7 +12,7 @@ from ..types import UNSET, InputFile, ResponseParameters if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -T = TypeVar("T") +TelegramType = TypeVar("TelegramType", bound=Any) class Request(BaseModel): @@ -31,14 +31,15 @@ class Request(BaseModel): } -class Response(ResponseParameters, GenericModel, Generic[T]): +class Response(GenericModel, Generic[TelegramType]): ok: bool - result: Optional[T] = None + result: Optional[TelegramType] = None description: Optional[str] = None error_code: Optional[int] = None + parameters: Optional[ResponseParameters] = None -class TelegramMethod(abc.ABC, BaseModel, Generic[T]): +class TelegramMethod(abc.ABC, BaseModel, Generic[TelegramType]): class Config(BaseConfig): # use_enum_values = True extra = Extra.allow @@ -76,14 +77,14 @@ class TelegramMethod(abc.ABC, BaseModel, Generic[T]): return super().dict(exclude=exclude, **kwargs) - def build_response(self, data: Dict[str, Any]) -> Response[T]: + def build_response(self, data: Dict[str, Any]) -> Response[TelegramType]: # noinspection PyTypeChecker return Response[self.__returning__](**data) # type: ignore - async def emit(self, bot: Bot) -> T: + async def emit(self, bot: Bot) -> TelegramType: return await bot(self) - def __await__(self) -> Generator[Any, None, T]: + def __await__(self) -> Generator[Any, None, TelegramType]: from aiogram.client.bot import Bot bot = Bot.get_current(no_error=False) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index d476c099..67b3a594 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -12,6 +12,9 @@ from .base import UNSET, TelegramObject if TYPE_CHECKING: # pragma: no cover from ..methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -1714,6 +1717,49 @@ class Message(TelegramObject): reply_markup=reply_markup, ) + def edit_text( + self, + text: str, + parse_mode: Optional[str] = UNSET, + entities: Optional[List[MessageEntity]] = None, + disable_web_page_preview: Optional[bool] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageText: + from ..methods import EditMessageText + + return EditMessageText( + chat_id=self.chat.id, + message_id=self.message_id, + text=text, + parse_mode=parse_mode, + entities=entities, + disable_web_page_preview=disable_web_page_preview, + reply_markup=reply_markup, + ) + + def edit_caption( + self, + caption: str, + parse_mode: Optional[str] = UNSET, + caption_entities: Optional[List[MessageEntity]] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageCaption: + from ..methods import EditMessageCaption + + return EditMessageCaption( + chat_id=self.chat.id, + message_id=self.message_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_markup=reply_markup, + ) + + def delete(self) -> DeleteMessage: + from ..methods import DeleteMessage + + return DeleteMessage(chat_id=self.chat.id, message_id=self.message_id) + class ContentType(helper.Helper): mode = helper.HelperMode.snake_case diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py deleted file mode 100644 index 3efe7032..00000000 --- a/aiogram/utils/exceptions.py +++ /dev/null @@ -1,563 +0,0 @@ -""" -- TelegramAPIError - - ValidationError - - Throttled - - BadRequest - - MessageError - - MessageNotModified - - MessageToForwardNotFound - - MessageToDeleteNotFound - - MessageIdentifierNotSpecified - - MessageTextIsEmpty - - MessageCantBeEdited - - MessageCantBeDeleted - - MessageToEditNotFound - - MessageToReplyNotFound - - ToMuchMessages - - PollError - - PollCantBeStopped - - PollHasAlreadyClosed - - PollsCantBeSentToPrivateChats - - PollSizeError - - PollMustHaveMoreOptions - - PollCantHaveMoreOptions - - PollsOptionsLengthTooLong - - PollOptionsMustBeNonEmpty - - PollQuestionMustBeNonEmpty - - MessageWithPollNotFound (with MessageError) - - MessageIsNotAPoll (with MessageError) - - ObjectExpectedAsReplyMarkup - - InlineKeyboardExpected - - ChatNotFound - - ChatDescriptionIsNotModified - - InvalidQueryID - - InvalidPeerID - - InvalidHTTPUrlContent - - ButtonURLInvalid - - URLHostIsEmpty - - StartParamInvalid - - ButtonDataInvalid - - WrongFileIdentifier - - GroupDeactivated - - BadWebhook - - WebhookRequireHTTPS - - BadWebhookPort - - BadWebhookAddrInfo - - BadWebhookNoAddressAssociatedWithHostname - - NotFound - - MethodNotKnown - - PhotoAsInputFileRequired - - InvalidStickersSet - - NoStickerInRequest - - ChatAdminRequired - - NeedAdministratorRightsInTheChannel - - MethodNotAvailableInPrivateChats - - CantDemoteChatCreator - - CantRestrictSelf - - NotEnoughRightsToRestrict - - PhotoDimensions - - UnavailableMembers - - TypeOfFileMismatch - - WrongRemoteFileIdSpecified - - PaymentProviderInvalid - - CurrencyTotalAmountInvalid - - CantParseUrl - - UnsupportedUrlProtocol - - CantParseEntities - - ResultIdDuplicate - - ConflictError - - TerminatedByOtherGetUpdates - - CantGetUpdates - - Unauthorized - - BotKicked - - BotBlocked - - UserDeactivated - - CantInitiateConversation - - CantTalkWithBots - - NetworkError - - RetryAfter - - MigrateToChat - - RestartingTelegram - -- AIOGramWarning - - TimeoutWarning -""" - - -class TelegramAPIError(Exception): - pass - - -# _PREFIXES = ["error: ", "[error]: ", "bad request: ", "conflict: ", "not found: "] -# - -# def _clean_message(text): -# for prefix in _PREFIXES: -# if text.startswith(prefix): -# text = text[len(prefix) :] -# return (text[0].upper() + text[1:]).strip() -# - - -# -# -# class _MatchErrorMixin: -# match = "" -# text = None -# -# __subclasses = [] -# -# def __init_subclass__(cls, **kwargs): -# super(_MatchErrorMixin, cls).__init_subclass__(**kwargs) -# # cls.match = cls.match.lower() if cls.match else '' -# if not hasattr(cls, f"_{cls.__name__}__group"): -# cls.__subclasses.append(cls) -# -# @classmethod -# def check(cls, message) -> bool: -# """ -# Compare pattern with message -# -# :param message: always must be in lowercase -# :return: bool -# """ -# return cls.match.lower() in message -# -# @classmethod -# def detect(cls, description): -# description = description.lower() -# for err in cls.__subclasses: -# if err is cls: -# continue -# if err.check(description): -# raise err(cls.text or description) -# raise cls(description) -# -# -# class AIOGramWarning(Warning): -# pass -# -# -# class TimeoutWarning(AIOGramWarning): -# pass -# -# -# class FSMStorageWarning(AIOGramWarning): -# pass -# -# -# class ValidationError(TelegramAPIError): -# pass -# -# -# class BadRequest(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MessageError(BadRequest): -# __group = True -# -# -# class MessageNotModified(MessageError): -# """ -# Will be raised when you try to set new text is equals to current text. -# """ -# -# match = "message is not modified" -# -# -# class MessageToForwardNotFound(MessageError): -# """ -# Will be raised when you try to forward very old or deleted or unknown message. -# """ -# -# match = "message to forward not found" -# -# -# class MessageToDeleteNotFound(MessageError): -# """ -# Will be raised when you try to delete very old or deleted or unknown message. -# """ -# -# match = "message to delete not found" -# -# -# class MessageToReplyNotFound(MessageError): -# """ -# Will be raised when you try to reply to very old or deleted or unknown message. -# """ -# -# match = "message to reply not found" -# -# -# class MessageIdentifierNotSpecified(MessageError): -# match = "message identifier is not specified" -# -# -# class MessageTextIsEmpty(MessageError): -# match = "Message text is empty" -# -# -# class MessageCantBeEdited(MessageError): -# match = "message can't be edited" -# -# -# class MessageCantBeDeleted(MessageError): -# match = "message can't be deleted" -# -# -# class MessageToEditNotFound(MessageError): -# match = "message to edit not found" -# -# -# class MessageIsTooLong(MessageError): -# match = "message is too long" -# -# -# class ToMuchMessages(MessageError): -# """ -# Will be raised when you try to send media group with more than 10 items. -# """ -# -# match = "Too much messages to send as an album" -# -# -# class ObjectExpectedAsReplyMarkup(BadRequest): -# match = "object expected as reply markup" -# -# -# class InlineKeyboardExpected(BadRequest): -# match = "inline keyboard expected" -# -# -# class PollError(BadRequest): -# __group = True -# -# -# class PollCantBeStopped(PollError): -# match = "poll can't be stopped" -# -# -# class PollHasAlreadyBeenClosed(PollError): -# match = "poll has already been closed" -# -# -# class PollsCantBeSentToPrivateChats(PollError): -# match = "polls can't be sent to private chats" -# -# -# class PollSizeError(PollError): -# __group = True -# -# -# class PollMustHaveMoreOptions(PollSizeError): -# match = "poll must have at least 2 option" -# -# -# class PollCantHaveMoreOptions(PollSizeError): -# match = "poll can't have more than 10 options" -# -# -# class PollOptionsMustBeNonEmpty(PollSizeError): -# match = "poll options must be non-empty" -# -# -# class PollQuestionMustBeNonEmpty(PollSizeError): -# match = "poll question must be non-empty" -# -# -# class PollOptionsLengthTooLong(PollSizeError): -# match = "poll options length must not exceed 100" -# -# -# class PollQuestionLengthTooLong(PollSizeError): -# match = "poll question length must not exceed 255" -# -# -# class MessageWithPollNotFound(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message with poll to stop not found" -# -# -# class MessageIsNotAPoll(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message is not a poll" -# -# -# class ChatNotFound(BadRequest): -# match = "chat not found" -# -# -# class ChatIdIsEmpty(BadRequest): -# match = "chat_id is empty" -# -# -# class InvalidUserId(BadRequest): -# match = "user_id_invalid" -# text = "Invalid user id" -# -# -# class ChatDescriptionIsNotModified(BadRequest): -# match = "chat description is not modified" -# -# -# class InvalidQueryID(BadRequest): -# match = "query is too old and response timeout expired or query id is invalid" -# -# -# class InvalidPeerID(BadRequest): -# match = "PEER_ID_INVALID" -# text = "Invalid peer ID" -# -# -# class InvalidHTTPUrlContent(BadRequest): -# match = "Failed to get HTTP URL content" -# -# -# class ButtonURLInvalid(BadRequest): -# match = "BUTTON_URL_INVALID" -# text = "Button URL invalid" -# -# -# class URLHostIsEmpty(BadRequest): -# match = "URL host is empty" -# -# -# class StartParamInvalid(BadRequest): -# match = "START_PARAM_INVALID" -# text = "Start param invalid" -# -# -# class ButtonDataInvalid(BadRequest): -# match = "BUTTON_DATA_INVALID" -# text = "Button data invalid" -# -# -# class WrongFileIdentifier(BadRequest): -# match = "wrong file identifier/HTTP URL specified" -# -# -# class GroupDeactivated(BadRequest): -# match = "group is deactivated" -# -# -# class PhotoAsInputFileRequired(BadRequest): -# """ -# Will be raised when you try to set chat photo from file ID. -# """ -# -# match = "Photo should be uploaded as an InputFile" -# -# -# class InvalidStickersSet(BadRequest): -# match = "STICKERSET_INVALID" -# text = "Stickers set is invalid" -# -# -# class NoStickerInRequest(BadRequest): -# match = "there is no sticker in the request" -# -# -# class ChatAdminRequired(BadRequest): -# match = "CHAT_ADMIN_REQUIRED" -# text = "Admin permissions is required!" -# -# -# class NeedAdministratorRightsInTheChannel(BadRequest): -# match = "need administrator rights in the channel chat" -# text = "Admin permissions is required!" -# -# -# class NotEnoughRightsToPinMessage(BadRequest): -# match = "not enough rights to pin a message" -# -# -# class MethodNotAvailableInPrivateChats(BadRequest): -# match = "method is available only for supergroups and channel" -# -# -# class CantDemoteChatCreator(BadRequest): -# match = "can't demote chat creator" -# -# -# class CantRestrictSelf(BadRequest): -# match = "can't restrict self" -# text = "Admin can't restrict self." -# -# -# class NotEnoughRightsToRestrict(BadRequest): -# match = "not enough rights to restrict/unrestrict chat member" -# -# -# class PhotoDimensions(BadRequest): -# match = "PHOTO_INVALID_DIMENSIONS" -# text = "Invalid photo dimensions" -# -# -# class UnavailableMembers(BadRequest): -# match = "supergroup members are unavailable" -# -# -# class TypeOfFileMismatch(BadRequest): -# match = "type of file mismatch" -# -# -# class WrongRemoteFileIdSpecified(BadRequest): -# match = "wrong remote file id specified" -# -# -# class PaymentProviderInvalid(BadRequest): -# match = "PAYMENT_PROVIDER_INVALID" -# text = "payment provider invalid" -# -# -# class CurrencyTotalAmountInvalid(BadRequest): -# match = "currency_total_amount_invalid" -# text = "currency total amount invalid" -# -# -# class BadWebhook(BadRequest): -# __group = True -# -# -# class WebhookRequireHTTPS(BadWebhook): -# match = "HTTPS url must be provided for webhook" -# text = "bad webhook: " + match -# -# -# class BadWebhookPort(BadWebhook): -# match = "Webhook can be set up only on ports 80, 88, 443 or 8443" -# text = "bad webhook: " + match -# -# -# class BadWebhookAddrInfo(BadWebhook): -# match = "getaddrinfo: Temporary failure in name resolution" -# text = "bad webhook: " + match -# -# -# class BadWebhookNoAddressAssociatedWithHostname(BadWebhook): -# match = "failed to resolve host: no address associated with hostname" -# -# -# class CantParseUrl(BadRequest): -# match = "can't parse URL" -# -# -# class UnsupportedUrlProtocol(BadRequest): -# match = "unsupported URL protocol" -# -# -# class CantParseEntities(BadRequest): -# match = "can't parse entities" -# -# -# class ResultIdDuplicate(BadRequest): -# match = "result_id_duplicate" -# text = "Result ID duplicate" -# -# -# class BotDomainInvalid(BadRequest): -# match = "bot_domain_invalid" -# text = "Invalid bot domain" -# -# -# class NotFound(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MethodNotKnown(NotFound): -# match = "method not found" -# -# -# class ConflictError(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class TerminatedByOtherGetUpdates(ConflictError): -# match = "terminated by other getUpdates request" -# text = ( -# "Terminated by other getUpdates request; " -# "Make sure that only one bot instance is running" -# ) -# -# -# class CantGetUpdates(ConflictError): -# match = "can't use getUpdates method while webhook is active" -# -# -# class Unauthorized(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class BotKicked(Unauthorized): -# match = "bot was kicked from a chat" -# -# -# class BotBlocked(Unauthorized): -# match = "bot was blocked by the user" -# -# -# class UserDeactivated(Unauthorized): -# match = "user is deactivated" -# -# -# class CantInitiateConversation(Unauthorized): -# match = "bot can't initiate conversation with a user" -# -# -# class CantTalkWithBots(Unauthorized): -# match = "bot can't send messages to bots" -# -# -# class NetworkError(TelegramAPIError): -# pass -# -# -# class RestartingTelegram(TelegramAPIError): -# def __init__(self): -# super(RestartingTelegram, self).__init__( -# "The Telegram Bot API service is restarting. Wait few second." -# ) -# -# -# class RetryAfter(TelegramAPIError): -# def __init__(self, retry_after): -# super(RetryAfter, self).__init__( -# f"Flood control exceeded. Retry in {retry_after} seconds." -# ) -# self.timeout = retry_after -# -# -# class MigrateToChat(TelegramAPIError): -# def __init__(self, chat_id): -# super(MigrateToChat, self).__init__( -# f"The group has been migrated to a supergroup. New id: {chat_id}." -# ) -# self.migrate_to_chat_id = chat_id -# -# -# class Throttled(TelegramAPIError): -# def __init__(self, **kwargs): -# from ..dispatcher.storage import DELTA, EXCEEDED_COUNT, KEY, LAST_CALL, RATE_LIMIT, RESULT -# -# self.key = kwargs.pop(KEY, "") -# self.called_at = kwargs.pop(LAST_CALL, time.time()) -# self.rate = kwargs.pop(RATE_LIMIT, None) -# self.result = kwargs.pop(RESULT, False) -# self.exceeded_count = kwargs.pop(EXCEEDED_COUNT, 0) -# self.delta = kwargs.pop(DELTA, 0) -# self.user = kwargs.pop("user", None) -# self.chat = kwargs.pop("chat", None) -# -# def __str__(self): -# return ( -# f"Rate limit exceeded! (Limit: {self.rate} s, " -# f"exceeded: {self.exceeded_count}, " -# f"time delta: {round(self.delta, 3)} s)" -# ) diff --git a/aiogram/utils/exceptions/exceptions.py b/aiogram/utils/exceptions/exceptions.py new file mode 100644 index 00000000..4ce6f0c2 --- /dev/null +++ b/aiogram/utils/exceptions/exceptions.py @@ -0,0 +1,93 @@ +from textwrap import indent +from typing import Match + +from aiogram.methods.base import TelegramMethod, TelegramType +from aiogram.utils.exceptions.base import DetailedTelegramAPIError +from aiogram.utils.exceptions.util import mark_line + + +class BadRequest(DetailedTelegramAPIError): + pass + + +class CantParseEntities(BadRequest): + pass + + +class CantParseEntitiesStartTag(CantParseEntities): + patterns = [ + "Bad Request: can't parse entities: Can't find end tag corresponding to start tag (?P.+)" + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.tag: str = match.group("tag") + + +class CantParseEntitiesUnmatchedTags(CantParseEntities): + patterns = [ + r'Bad Request: can\'t parse entities: Unmatched end tag at byte offset (?P\d), expected "\w+)>", found "\w+)>"' + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset: int = int(match.group("offset")) + self.expected: str = match.group("expected") + self.found: str = match.group("found") + + +class CantParseEntitiesUnclosed(CantParseEntities): + patterns = [ + "Bad Request: can't parse entities: Unclosed start tag at byte offset (?P.+)" + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset: int = int(match.group("offset")) + + def __str__(self) -> str: + message = [self.message] + text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) + if text: + message.extend(["Example:", indent(mark_line(text, self.offset), prefix=" ")]) + return "\n".join(message) + + +class CantParseEntitiesUnsupportedTag(CantParseEntities): + patterns = [ + r'Bad Request: can\'t parse entities: Unsupported start tag "(?P.+)" at byte offset (?P\d+)' + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset = int(match.group("offset")) + self.tag = match.group("tag") + + def __str__(self) -> str: + message = [self.message] + text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) + if text: + message.extend( + ["Example:", indent(mark_line(text, self.offset, len(self.tag)), prefix=" ")] + ) + return "\n".join(message) diff --git a/aiogram/utils/markup.py b/aiogram/utils/keyboard.py similarity index 83% rename from aiogram/utils/markup.py rename to aiogram/utils/keyboard.py index 32169104..19409c94 100644 --- a/aiogram/utils/markup.py +++ b/aiogram/utils/keyboard.py @@ -1,8 +1,16 @@ +from __future__ import annotations + from itertools import chain from itertools import cycle as repeat_all -from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar +from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar, Union -from aiogram.types import InlineKeyboardButton, KeyboardButton +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, +) ButtonType = TypeVar("ButtonType", InlineKeyboardButton, KeyboardButton) T = TypeVar("T") @@ -11,7 +19,7 @@ MIN_WIDTH = 1 MAX_BUTTONS = 100 -class MarkupConstructor(Generic[ButtonType]): +class KeyboardConstructor(Generic[ButtonType]): def __init__( self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None ) -> None: @@ -106,7 +114,7 @@ class MarkupConstructor(Generic[ButtonType]): raise ValueError(f"Row size {size} are not allowed") return size - def copy(self: "MarkupConstructor[ButtonType]") -> "MarkupConstructor[ButtonType]": + def copy(self: "KeyboardConstructor[ButtonType]") -> "KeyboardConstructor[ButtonType]": """ Make full copy of current constructor with markup @@ -120,7 +128,7 @@ class MarkupConstructor(Generic[ButtonType]): .. code-block:: python - >>> constructor = MarkupConstructor(button_type=InlineKeyboardButton) + >>> constructor = KeyboardConstructor(button_type=InlineKeyboardButton) >>> ... # Add buttons to constructor >>> markup = InlineKeyboardMarkup(inline_keyboard=constructor.export()) @@ -128,7 +136,7 @@ class MarkupConstructor(Generic[ButtonType]): """ return self._markup.copy() - def add(self, *buttons: ButtonType) -> "MarkupConstructor[ButtonType]": + def add(self, *buttons: ButtonType) -> "KeyboardConstructor[ButtonType]": """ Add one or many buttons to markup. @@ -153,7 +161,9 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "MarkupConstructor[ButtonType]": + def row( + self, *buttons: ButtonType, width: int = MAX_WIDTH + ) -> "KeyboardConstructor[ButtonType]": """ Add row to markup @@ -170,7 +180,7 @@ class MarkupConstructor(Generic[ButtonType]): ) return self - def adjust(self, *sizes: int, repeat: bool = False) -> "MarkupConstructor[ButtonType]": + def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardConstructor[ButtonType]": """ Adjust previously added buttons to specific row sizes. @@ -202,10 +212,17 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def button(self, **kwargs: Any) -> "MarkupConstructor[ButtonType]": + def button(self, **kwargs: Any) -> "KeyboardConstructor[ButtonType]": + if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData): + kwargs["callback_data"] = callback_data.pack() button = self._button_type(**kwargs) return self.add(button) + def as_markup(self, **kwargs: Any) -> Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]: + if self._button_type is ReplyKeyboardMarkup: + return ReplyKeyboardMarkup(keyboard=self.export(), **kwargs) + return InlineKeyboardMarkup(inline_keyboard=self.export()) + def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: items_iter = iter(items) diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 2ab06c78..f5689c3d 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -1,39 +1,46 @@ +import logging from typing import Any from aiogram import Bot, Dispatcher, types -from aiogram.dispatcher.handler import MessageHandler +from aiogram.types import Message TOKEN = "42:TOKEN" dp = Dispatcher() +logger = logging.getLogger(__name__) -@dp.message(commands=["start"]) -class MyHandler(MessageHandler): + +@dp.message(commands={"start"}) +async def command_start_handler(message: Message) -> None: """ This handler receive messages with `/start` command - - Usage of Class-based handlers """ - - async def handle(self) -> Any: - await self.event.answer(f"Hello, {self.from_user.full_name}!") + # Most of event objects has an aliases for API methods to be called in event context + # For example if you want to answer to incoming message you can use `message.answer(...)` alias + # and the target chat will be passed to :ref:`aiogram.methods.send_message.SendMessage` method automatically + # or call API method directly via Bot instance: `bot.send_message(chat_id=message.chat.id, ...)` + await message.answer(f"Hello, {message.from_user.full_name}!") -@dp.message(content_types=[types.ContentType.ANY]) -async def echo_handler(message: types.Message, bot: Bot) -> Any: +@dp.message() +async def echo_handler(message: types.Message) -> Any: """ Handler will forward received message back to the sender - Usage of Function-based handlers + By default message handler will handle all message types (like text, photo, sticker and etc.) """ - - await bot.forward_message( - from_chat_id=message.chat.id, chat_id=message.chat.id, message_id=message.message_id - ) + try: + # Send copy of the received message + await message.send_copy(chat_id=message.chat.id) + except TypeError: + # But not all the types is supported to be copied so need to handle it + await message.answer("Nice try!") def main() -> None: + # Initialize Bot instance with an default parse mode which will be passed to all API calls bot = Bot(TOKEN, parse_mode="HTML") + # And the run events dispatching dp.run_polling(bot) diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py index aee31398..03e48e4f 100644 --- a/tests/mocked_bot.py +++ b/tests/mocked_bot.py @@ -4,17 +4,17 @@ from typing import TYPE_CHECKING, AsyncGenerator, Deque, Optional, Type from aiogram import Bot from aiogram.client.session.base import BaseSession from aiogram.methods import TelegramMethod -from aiogram.methods.base import Request, Response, T -from aiogram.types import UNSET +from aiogram.methods.base import Request, Response, TelegramType +from aiogram.types import UNSET, ResponseParameters class MockedSession(BaseSession): def __init__(self): super(MockedSession, self).__init__() - self.responses: Deque[Response[T]] = deque() + self.responses: Deque[Response[TelegramType]] = deque() self.requests: Deque[Request] = deque() - def add_result(self, response: Response[T]) -> Response[T]: + def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: self.responses.append(response) return response @@ -25,11 +25,13 @@ class MockedSession(BaseSession): pass async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: self.requests.append(method.build_request(bot)) - response: Response[T] = self.responses.pop() - self.raise_for_status(response) + response: Response[TelegramType] = self.responses.pop() + self.check_response( + method=method, status_code=response.error_code, content=response.json() + ) return response.result # type: ignore async def stream_content( @@ -47,21 +49,23 @@ class MockedBot(Bot): def add_result_for( self, - method: Type[TelegramMethod[T]], + method: Type[TelegramMethod[TelegramType]], ok: bool, - result: T = None, + result: TelegramType = None, description: Optional[str] = None, error_code: Optional[int] = None, migrate_to_chat_id: Optional[int] = None, retry_after: Optional[int] = None, - ) -> Response[T]: + ) -> Response[TelegramType]: response = Response[method.__returning__]( # type: ignore ok=ok, result=result, description=description, error_code=error_code, - migrate_to_chat_id=migrate_to_chat_id, - retry_after=retry_after, + parameters=ResponseParameters( + migrate_to_chat_id=migrate_to_chat_id, + retry_after=retry_after, + ), ) self.session.add_result(response) return response diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index 42ee7e3e..9624642e 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -172,14 +172,10 @@ class TestAiohttpSession: return Request(method="method", data={}) call = TestMethod() - with patch( - "aiogram.client.session.base.BaseSession.raise_for_status" - ) as patched_raise_for_status: - result = await session.make_request(bot, call) - assert isinstance(result, int) - assert result == 42 - assert patched_raise_for_status.called_once() + result = await session.make_request(bot, call) + assert isinstance(result, int) + assert result == 42 @pytest.mark.asyncio async def test_stream_content(self, aresponses: ResponsesMockServer): diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index 3ed8a126..448f663e 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -4,9 +4,9 @@ from typing import AsyncContextManager, AsyncGenerator, Optional import pytest -from aiogram.client.session.base import BaseSession, T +from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.telegram import PRODUCTION, TelegramAPIServer -from aiogram.methods import GetMe, Response, TelegramMethod +from aiogram.methods import DeleteMessage, GetMe, Response, TelegramMethod from aiogram.types import UNSET try: @@ -20,7 +20,7 @@ class CustomSession(BaseSession): async def close(self): pass - async def make_request(self, token: str, method: TelegramMethod[T], timeout: Optional[int] = UNSET) -> None: # type: ignore + async def make_request(self, token: str, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET) -> None: # type: ignore assert isinstance(token, str) assert isinstance(method, TelegramMethod) @@ -135,12 +135,20 @@ class TestBaseSession: assert session.clean_json(42) == 42 - def test_raise_for_status(self): + def check_response(self): session = CustomSession() - session.raise_for_status(Response[bool](ok=True, result=True)) + session.check_response( + method=DeleteMessage(chat_id=42, message_id=42), + status_code=200, + content='{"ok":true,"result":true}', + ) with pytest.raises(Exception): - session.raise_for_status(Response[bool](ok=False, description="Error", error_code=400)) + session.check_response( + method=DeleteMessage(chat_id=42, message_id=42), + status_code=400, + content='{"ok":false,"description":"test"}', + ) @pytest.mark.asyncio async def test_make_request(self): diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py index 5e56ed8a..bea20f08 100644 --- a/tests/test_api/test_types/test_message.py +++ b/tests/test_api/test_types/test_message.py @@ -5,6 +5,9 @@ import pytest from aiogram.methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -549,3 +552,28 @@ class TestMessage: if method: assert isinstance(method, expected_method) # TODO: Check additional fields + + def test_edit_text(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_text(text="test") + assert isinstance(method, EditMessageText) + assert method.chat_id == message.chat.id + + def test_edit_caption(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_caption(caption="test") + assert isinstance(method, EditMessageCaption) + assert method.chat_id == message.chat.id + + def test_delete(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.delete() + assert isinstance(method, DeleteMessage) + assert method.chat_id == message.chat.id + assert method.message_id == message.message_id