Browse Source

Rework captcha handling and type errors

pull/10109/head
dolfies 2 years ago
parent
commit
bb952c7060
  1. 1
      discord/__init__.py
  2. 54
      discord/client.py
  3. 102
      discord/errors.py
  4. 108
      discord/handlers.py
  5. 19
      discord/http.py
  6. 57
      discord/types/error.py
  7. 13
      docs/api.rst

1
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 *

54
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 <https://docs.aiohttp.org/en/stable/client_advanced.html#client-tracing>`_.
.. 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()

102
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

108
discord/handlers.py

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

19
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

57
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]

13
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

Loading…
Cancel
Save