Added more aliases, refactor CallbackData factory, added base exceptions classification mechanism

This commit is contained in:
Alex Root Junior 2021-05-25 00:56:44 +03:00
parent 9451a085d1
commit f022b4441c
18 changed files with 364 additions and 664 deletions

View file

@ -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:
"""

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]:

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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, "<None>")
# 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)"
# )

View file

@ -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<tag>.+)"
]
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<offset>\d), expected "</(?P<expected>\w+)>", found "</(?P<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<offset>.+)"
]
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<tag>.+)" at byte offset (?P<offset>\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)

View file

@ -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)

View file

@ -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"<b>Hello, {self.from_user.full_name}!</b>")
# 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, <b>{message.from_user.full_name}!</b>")
@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)

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

@ -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