From bb952c7060843a7c2bb606af02218f52405f538b Mon Sep 17 00:00:00 2001 From: dolfies Date: Thu, 31 Aug 2023 00:12:54 +0300 Subject: [PATCH] Rework captcha handling and type errors --- discord/__init__.py | 1 - discord/client.py | 54 +++++++++++++++++---- discord/errors.py | 102 +++++++++++++++++++++++++++----------- discord/handlers.py | 108 ----------------------------------------- discord/http.py | 19 +++----- discord/types/error.py | 57 ++++++++++++++++++++++ docs/api.rst | 13 ++--- 7 files changed, 186 insertions(+), 168 deletions(-) delete mode 100644 discord/handlers.py create mode 100644 discord/types/error.py diff --git a/discord/__init__.py b/discord/__init__.py index 93bc013f4..cdc6f6f94 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -45,7 +45,6 @@ from .file import * from .flags import * from .guild import * from .guild_premium import * -from .handlers import * from .integrations import * from .interactions import * from .invite import * diff --git a/discord/client.py b/discord/client.py index fa4b152e6..c2a547582 100644 --- a/discord/client.py +++ b/discord/client.py @@ -30,6 +30,7 @@ import logging from typing import ( Any, AsyncIterator, + Awaitable, Callable, Collection, Coroutine, @@ -78,7 +79,6 @@ from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factor from .profile import UserProfile from .connections import Connection from .team import Team -from .handlers import CaptchaHandler from .billing import PaymentSource, PremiumUsage from .subscriptions import Subscription, SubscriptionItem, SubscriptionInvoice from .payments import Payment @@ -222,10 +222,14 @@ class Client: `aiohttp documentation `_. .. versionadded:: 2.0 - captcha_handler: Optional[:class:`CaptchaHandler`] - A class that solves captcha challenges. + captcha_handler: Optional[Callable[[:class:`.CaptchaRequired`, :class:`.Client`], Awaitable[:class:`str`]] + A function that solves captcha challenges. .. versionadded:: 2.0 + + .. versionchanged:: 2.1 + + Now accepts a coroutine instead of a ``CaptchaHandler``. max_ratelimit_timeout: Optional[:class:`float`] The maximum number of seconds to wait when a non-global rate limit is encountered. If a request requires sleeping for more than the seconds passed in, then @@ -251,17 +255,16 @@ class Client: proxy_auth: Optional[aiohttp.BasicAuth] = options.pop('proxy_auth', None) unsync_clock: bool = options.pop('assume_unsync_clock', True) http_trace: Optional[aiohttp.TraceConfig] = options.pop('http_trace', None) - captcha_handler: Optional[CaptchaHandler] = options.pop('captcha_handler', None) - if captcha_handler is not None and not isinstance(captcha_handler, CaptchaHandler): - raise TypeError(f'captcha_handler must derive from CaptchaHandler') max_ratelimit_timeout: Optional[float] = options.pop('max_ratelimit_timeout', None) + self.captcha_handler: Optional[Callable[[CaptchaRequired, Client], Awaitable[str]]] = options.pop( + 'captcha_handler', None + ) self.http: HTTPClient = HTTPClient( - self.loop, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, http_trace=http_trace, - captcha_handler=captcha_handler, + captcha=self.handle_captcha, max_ratelimit_timeout=max_ratelimit_timeout, locale=lambda: self._connection.locale, ) @@ -549,6 +552,8 @@ class Client: def get_experiment(self, experiment: Union[str, int], /) -> Optional[Union[UserExperiment, GuildExperiment]]: """Returns a user or guild experiment from the given experiment identifier. + .. versionadded:: 2.1 + Parameters ----------- experiment: Union[:class:`str`, :class:`int`] @@ -668,7 +673,7 @@ class Client: """ _log.exception('Ignoring exception in %s', event_method) - async def on_internal_settings_update(self, old_settings: UserSettings, new_settings: UserSettings): + async def on_internal_settings_update(self, old_settings: UserSettings, new_settings: UserSettings, /): if not self._sync_presences: return @@ -712,11 +717,40 @@ class Client: """ pass + async def handle_captcha(self, exception: CaptchaRequired, /) -> str: + """|coro| + + Handles a CAPTCHA challenge and returns a solution. + + The default implementation tries to use the CAPTCHA handler + passed in the constructor. + + .. versionadded:: 2.1 + + Parameters + ------------ + exception: :class:`.CaptchaRequired` + The exception that was raised. + + Raises + -------- + CaptchaRequired + The CAPTCHA challenge could not be solved. + + Returns + -------- + :class:`str` + The solution to the CAPTCHA challenge. + """ + handler = self.captcha_handler + if handler is None: + raise exception + return await handler(exception, self) + async def _async_setup_hook(self) -> None: # Called whenever the client needs to initialise asyncio objects with a running loop loop = asyncio.get_running_loop() self.loop = loop - self.http.loop = loop self._connection.loop = loop await self._connection.async_setup() diff --git a/discord/errors.py b/discord/errors.py index dc4035231..667edbf48 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -23,13 +23,23 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import Dict, List, Optional, TYPE_CHECKING, Any, Tuple, Union + +from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Tuple, Union from .utils import _get_as_snowflake if TYPE_CHECKING: from aiohttp import ClientResponse, ClientWebSocketResponse from requests import Response + from typing_extensions import TypeGuard + + from .types.error import ( + CaptchaRequired as CaptchaPayload, + CaptchaService, + Error as ErrorPayload, + FormErrors as FormErrorsPayload, + FormErrorWrapper as FormErrorWrapperPayload, + ) _ResponseType = Union[ClientResponse, Response] @@ -56,7 +66,7 @@ class DiscordException(Exception): Ideally speaking, this could be caught to handle any exceptions raised from this library. """ - pass + __slots__ = () class ClientException(DiscordException): @@ -65,7 +75,7 @@ class ClientException(DiscordException): These are usually for exceptions that happened due to user input. """ - pass + __slots__ = () class GatewayNotFound(DiscordException): @@ -76,25 +86,27 @@ class GatewayNotFound(DiscordException): super().__init__(message) -def _flatten_error_dict(d: Dict[str, Any], key: str = '') -> Dict[str, str]: +def _flatten_error_dict(d: FormErrorsPayload, key: str = '', /) -> Dict[str, str]: + def is_wrapper(x: FormErrorsPayload) -> TypeGuard[FormErrorWrapperPayload]: + return '_errors' in x + items: List[Tuple[str, str]] = [] - if '_errors' in d: - items.append(('miscallenous', ' '.join(x.get('message', '') for x in d['_errors']))) - d.pop('_errors') + if is_wrapper(d) and not key: + items.append(('miscellaneous', ' '.join(x.get('message', '') for x in d['_errors']))) + d.pop('_errors') # type: ignore for k, v in d.items(): new_key = key + '.' + k if key else k if isinstance(v, dict): - try: - _errors: List[Dict[str, Any]] = v['_errors'] - except KeyError: - items.extend(_flatten_error_dict(v, new_key).items()) - else: + if is_wrapper(v): + _errors = v['_errors'] items.append((new_key, ' '.join(x.get('message', '') for x in _errors))) + else: + items.extend(_flatten_error_dict(v, new_key).items()) else: - items.append((new_key, v)) + items.append((new_key, v)) # type: ignore return dict(items) @@ -127,12 +139,12 @@ class HTTPException(DiscordException): def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]): self.response: _ResponseType = response self.status: int = response.status # type: ignore # This attribute is filled by the library even if using requests - self.code: int + self.code: int = 0 self.text: str - self.json: Dict[str, Any] - self.payment_id: Optional[int] + self.json: ErrorPayload + self.payment_id: Optional[int] = None if isinstance(message, dict): - self.json = message + self.json = message # type: ignore self.code = message.get('code', 0) base = message.get('message', '') errors = message.get('errors') @@ -145,9 +157,7 @@ class HTTPException(DiscordException): self.payment_id = _get_as_snowflake(message, 'payment_id') else: self.text = message or '' - self.code = 0 - self.json = {} - self.payment_id = None + self.json = {'code': 0, 'message': message or ''} fmt = '{0.status} {0.reason} (error code: {1})' if len(self.text): @@ -175,6 +185,8 @@ class RateLimited(DiscordException): the request. """ + __slots__ = ('retry_after',) + def __init__(self, retry_after: float): self.retry_after = retry_after super().__init__(f'Too many requests. Retry in {retry_after:.2f} seconds.') @@ -186,7 +198,7 @@ class Forbidden(HTTPException): Subclass of :exc:`HTTPException` """ - pass + __slots__ = () class NotFound(HTTPException): @@ -195,7 +207,7 @@ class NotFound(HTTPException): Subclass of :exc:`HTTPException` """ - pass + __slots__ = () class DiscordServerError(HTTPException): @@ -206,20 +218,52 @@ class DiscordServerError(HTTPException): .. versionadded:: 1.5 """ - pass + __slots__ = () class CaptchaRequired(HTTPException): - """Exception that's raised when a captcha is required and isn't handled. + """Exception that's raised when a CAPTCHA is required and isn't handled. Subclass of :exc:`HTTPException`. .. versionadded:: 2.0 + + Attributes + ------------ + errors: List[:class:`str`] + The CAPTCHA service errors. + + .. versionadded:: 2.1 + service: :class:`str` + The CAPTCHA service to use. Usually ``hcaptcha``. + + .. versionadded:: 2.1 + sitekey: :class:`str` + The CAPTCHA sitekey to use. + + .. versionadded:: 2.1 + rqdata: Optional[:class:`str`] + The enterprise hCaptcha request data. + + .. versionadded:: 2.1 + rqtoken: Optional[:class:`str`] + The enterprise hCaptcha request token. + + .. versionadded:: 2.1 """ - def __init__(self, response: _ResponseType, message: Dict[str, Any]): + RECAPTCHA_SITEKEY: Final[str] = '6Lef5iQTAAAAAKeIvIY-DeexoO3gj7ryl9rLMEnn' + + __slots__ = ('errors', 'service', 'sitekey') + + def __init__(self, response: _ResponseType, message: CaptchaPayload): super().__init__(response, {'code': -1, 'message': 'Captcha required'}) - self.json = message + self.json: CaptchaPayload = message + self.errors: List[str] = message['captcha_key'] + self.service: CaptchaService = message.get('captcha_service', 'hcaptcha') + self.sitekey: str = message.get('captcha_sitekey') or self.RECAPTCHA_SITEKEY + self.rqdata: Optional[str] = message.get('captcha_rqdata') + self.rqtoken: Optional[str] = message.get('captcha_rqtoken') class InvalidData(ClientException): @@ -227,7 +271,7 @@ class InvalidData(ClientException): or invalid data from Discord. """ - pass + __slots__ = () class LoginFailure(ClientException): @@ -236,7 +280,7 @@ class LoginFailure(ClientException): failure. """ - pass + __slots__ = () AuthFailure = LoginFailure @@ -254,6 +298,8 @@ class ConnectionClosed(ClientException): The reason provided for the closure. """ + __slots__ = ('code', 'reason') + def __init__(self, socket: ClientWebSocketResponse, *, code: Optional[int] = None): # This exception is just the same exception except # reconfigured to subclass ClientException for users diff --git a/discord/handlers.py b/discord/handlers.py deleted file mode 100644 index c8a998f77..000000000 --- a/discord/handlers.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021-present Dolfies - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import Any, Dict, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from aiohttp import BasicAuth - -# fmt: off -__all__ = ( - 'CaptchaHandler', -) -# fmt: on - - -class CaptchaHandler: - """A class that represents a captcha handler. - - This class allows you to implement a protocol to solve captchas required by Discord. - This is an abstract class. The library provides no concrete implementation. - - These classes are passed to :class:`Client`. - """ - - async def startup(self): - """|coro| - - An abstract method that is called by the library at startup. - - This is meant to provide an async startup method for the handler. - This isn't guaranteed to be run once. - - The default implementation does nothing. - """ - pass - - async def prefetch_token(self, proxy: Optional[str], proxy_auth: Optional[BasicAuth], /) -> None: - """|coro| - - An abstract method that is called a bit before a captcha token is required. - Not guaranteed to be called. - - It's meant to signal the handler to begin preparing for the fetching of a token, if applicable. - Keep in mind that Discord has multiple captcha sitekeys. - - The default implementation does nothing. - - Parameters - ---------- - proxy: Optional[:class:`str`] - The current proxy of the client. - proxy_auth: Optional[:class:`aiohttp.BasicAuth`] - The proxy's auth. - """ - pass - - async def fetch_token( - self, - data: Dict[str, Any], - proxy: Optional[str], - proxy_auth: Optional[BasicAuth], - /, - ) -> str: - """|coro| - - An abstract method that is called to fetch a captcha token. - - If there is no token available, it should wait until one is - generated before returning. - - Parameters - ------------ - data: Dict[:class:`str`, :class:`Any`] - The raw error from Discord containing the captcha info. - proxy: Optional[:class:`str`] - The current proxy of the client. - proxy_auth: Optional[:class:`aiohttp.BasicAuth`] - The proxy's auth. - - Returns - -------- - :class:`str` - A captcha token. - """ - raise NotImplementedError diff --git a/discord/http.py b/discord/http.py index af83f129e..d9789b0e7 100644 --- a/discord/http.py +++ b/discord/http.py @@ -75,7 +75,6 @@ if TYPE_CHECKING: from typing_extensions import Self from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable, VoiceChannel, ForumChannel - from .handlers import CaptchaHandler from .threads import Thread from .mentions import AllowedMentions from .message import Attachment, Message @@ -568,18 +567,16 @@ class HTTPClient: def __init__( self, - loop: asyncio.AbstractEventLoop, connector: Optional[aiohttp.BaseConnector] = None, *, proxy: Optional[str] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, unsync_clock: bool = True, http_trace: Optional[aiohttp.TraceConfig] = None, - captcha_handler: Optional[CaptchaHandler] = None, + captcha: Optional[Callable[[CaptchaRequired], Coroutine[Any, Any, str]]] = None, max_ratelimit_timeout: Optional[float] = None, locale: Callable[[], str] = lambda: 'en-US', ) -> None: - self.loop: asyncio.AbstractEventLoop = loop self.connector: aiohttp.BaseConnector = connector or MISSING self.__session: aiohttp.ClientSession = MISSING # Route key -> Bucket hash @@ -598,7 +595,7 @@ class HTTPClient: self.proxy_auth: Optional[aiohttp.BasicAuth] = proxy_auth self.http_trace: Optional[aiohttp.TraceConfig] = http_trace self.use_clock: bool = not unsync_clock - self.captcha_handler: Optional[CaptchaHandler] = captcha_handler + self.captcha_handler: Optional[Callable[[CaptchaRequired], Coroutine[Any, Any, str]]] = captcha self.max_ratelimit_timeout: Optional[float] = max(30.0, max_ratelimit_timeout) if max_ratelimit_timeout else None self.get_locale: Callable[[], str] = locale @@ -635,9 +632,6 @@ class HTTPClient: self.super_properties, self.encoded_super_properties = sp, _ = await utils._get_info(session) _log.info('Found user agent %s, build number %s.', sp.get('browser_user_agent'), sp.get('client_build_number')) - if self.captcha_handler is not None: - await self.captcha_handler.startup() - self._started = True async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse: @@ -930,7 +924,7 @@ class HTTPClient: raise DiscordServerError(response, data) else: if isinstance(data, dict) and 'captcha_key' in data: - raise CaptchaRequired(response, data) + raise CaptchaRequired(response, data) # type: ignore raise HTTPException(response, data) # This is handling exceptions from the request @@ -950,10 +944,9 @@ class HTTPClient: if captcha_handler is None or tries == 4: raise else: - headers['X-Captcha-Key'] = await captcha_handler.fetch_token(e.json, self.proxy, self.proxy_auth) - rqtoken = e.json.get('captcha_rqtoken') - if rqtoken: - headers['X-Captcha-Rqtoken'] = rqtoken + headers['X-Captcha-Key'] = await captcha_handler(e) + if e.rqtoken: + headers['X-Captcha-Rqtoken'] = e.rqtoken if response is not None: # We've run out of retries, raise diff --git a/discord/types/error.py b/discord/types/error.py new file mode 100644 index 000000000..6b48b7caf --- /dev/null +++ b/discord/types/error.py @@ -0,0 +1,57 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Dict, List, Literal, Optional, TypedDict, Union +from typing_extensions import NotRequired + + +class FormError(TypedDict): + code: str + message: str + + +class FormErrorWrapper(TypedDict): + _errors: List[FormError] + + +FormErrors = Union[FormErrorWrapper, Dict[str, 'FormErrors']] + + +class Error(TypedDict): + code: int + message: str + errors: NotRequired[FormErrors] + + +CaptchaService = Literal['hcaptcha', 'recaptcha'] + + +class CaptchaRequired(TypedDict): + captcha_key: List[str] + captcha_service: CaptchaService + captcha_sitekey: Optional[str] + captcha_rqdata: NotRequired[str] + captcha_rqtoken: NotRequired[str] diff --git a/docs/api.rst b/docs/api.rst index 9a8b86fd7..9b2a20693 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -45,14 +45,6 @@ Client .. automethod:: Client.event() :decorator: -CaptchaHandler -~~~~~~~~~~~~~~ - -.. attributetable:: CaptchaHandler - -.. autoclass:: CaptchaHandler - :members: - Voice Related --------------- @@ -8108,8 +8100,12 @@ The following exceptions are thrown by the library. :members: .. autoexception:: Forbidden + :members: + :inherited-members: .. autoexception:: NotFound + :members: + :inherited-members: .. autoexception:: CaptchaRequired :members: @@ -8122,6 +8118,7 @@ The following exceptions are thrown by the library. .. autoexception:: GatewayNotFound .. autoexception:: ConnectionClosed + :members: .. autoexception:: discord.opus.OpusError